Offer native CHOAM Homeworld discard costs to Semuta

This commit is contained in:
Quintine committed 2026-10-13 01:12:02 +11:00
1 parent 68f7a101a3
commit 506bf25bc6
13 files changed
+762 -10

No files matched your search

@@ -0,0 +1,154 @@
import assert from 'node:assert/strict';
import { handLimit, viewGame, type Action, type Game } from '../game/engine';
import { TERRITORIES, gameDistance, location, splitLocation } from '../game/board';
import { nativeShipmentSources } from '../game/homeworld-options';
import { choamPowerAction, choamPowerPlays } from '../game/choam-power-options';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import { advanceSemutaHomeworldChoam, applySemutaHomeworldChoamStep,
createSemutaHomeworldChoamSetup, holdSemutaHomeworldChoamCard } from './fixture-semuta-homeworld-choam';
import { moveSemutaEmperorCard, semutaEmperorPlayer } from './fixture-semuta-emperor-share';
import type { RicheseSkillsTechStep } from './fixture-richese-skills-tech';
export type SemutaHomeworldChoamMovementEffect = 'kulon' | 'baliset';
export type SemutaHomeworldChoamMovementFixture = {
setup: Game; game: Game; beforeShipment: Game; afterShipment: Game; beforeMove: Game;
owner: string; holder: string; target: string; canceller: string; card: string;
effect: SemutaHomeworldChoamMovementEffect; action: Action; shipment: Action;
move: Action; tooFar: Action | null; alternateMove: Action | null;
origin: string; destination: string; actions: RicheseSkillsTechStep[]; staging: string[];
};
const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision && !game.pendingTreacheryDiscard;
export function semutaHomeworldChoamMovementStock(game: Game): string[] {
return [game.deck, game.discard, game.richeseCache ?? [], game.richeseRemoved ?? [],
...game.players.map(player => player.hand)].flat().map(card => card.id).sort();
}
export function semutaHomeworldChoamMovementResources(game: Game) {
return { custody: structuredClone(game.homeworlds?.custody),
players: game.players.map(player => ({ id: player.id, spice: player.spice,
reserves: player.reserves, tanks: player.tanks, forces: structuredClone(player.forces),
elites: structuredClone(player.elites), shipped: player.shipped, moved: player.moved,
ally: player.ally })), spice: structuredClone(game.spice) };
}
function destinationAtRange(game: Game, origin: string, range: number, excluded: string[] = []) {
const destination = TERRITORIES.filter(t => t.type === 'sand' && !excluded.includes(t.id))
.flatMap(t => t.sectors.map(sector => location(t.id, sector)))
.find(key => splitLocation(key).sector !== game.storm &&
game.players.every(player => !(player.forces[key] > 0)) &&
gameDistance(game, origin, key, k => splitLocation(k).sector === game.storm) === range);
assert.ok(destination, `Original board has a free sand destination at range ${range}`);
return destination;
}
/** Original setup, first-turn order and paid native reserves shipment. Only
* conserved physical hands and two still-unplayed Spice Cards are positioned. */
export function createSemutaHomeworldChoamMovementFixture(
effect: SemutaHomeworldChoamMovementEffect, advanced = false, fullHolder = false,
): SemutaHomeworldChoamMovementFixture {
const native = createSemutaHomeworldChoamSetup(advanced);
let game = native.game;
const setup = structuredClone(game), actions = native.actions;
const staging = ['Conserved original physical Semuta in Richese hand.'];
const blows = game.spiceDeck.filter(card => 'territory' in card).slice(0, 2);
assert.equal(blows.length, 2);
for (const [position, card] of blows.entries())
game.spiceDeck.splice(position, 0, game.spiceDeck.splice(game.spiceDeck.indexOf(card), 1)[0]);
staging.push('Reorder two original unplayed non-worm Spice Cards; no new deck.');
game = advanceSemutaHomeworldChoam(game,
g => g.phase === 5 && g.active === native.owner && clean(g), actions);
assert.equal(game.turn, 1);
const card = holdSemutaHomeworldChoamCard(game, native.owner, effect === 'kulon' ? 'Kulon' : 'Baliset');
holdSemutaHomeworldChoamCard(game, native.canceller, 'Karama');
staging.push(`Conserved printed ${effect} to CHOAM and original Karama to Emperor.`);
assert.ok(semutaEmperorPlayer(game, native.owner).hand.length <= handLimit(semutaEmperorPlayer(game, native.owner)));
if (fullHolder) {
const holder = semutaEmperorPlayer(game, native.holder);
while (holder.hand.length < handLimit(holder)) {
assert.ok(game.deck[0]);
moveSemutaEmperorCard(game, game.deck[0].id, holder.id);
}
staging.push('Conserved original undealt cards fill Richese to its native hand limit.');
}
const origin = effect === 'kulon' ? 'red_chasm:7'
: Object.keys(semutaEmperorPlayer(game, native.target).forces).find(key => semutaEmperorPlayer(game, native.target).forces[key] > 0)!;
assert.ok(origin);
const destination = destinationAtRange(game, origin, effect === 'kulon' ? 2 : 1);
const shipTo = effect === 'kulon' ? origin : destination;
const sources = nativeShipmentSources(viewGame(game, native.owner), 1, 0);
assert.ok(sources);
const shipment: Action = { type: 'ship', ...splitLocation(shipTo), amount: 1, elite: 0,
homeworldSources: sources, allyPayment: 0 };
const beforeShipment = structuredClone(game);
game = applySemutaHomeworldChoamStep(game, native.owner, shipment, actions);
game = advanceSemutaHomeworldChoam(game, g => clean(g), actions);
const afterShipment = structuredClone(game);
const move: Action = { type: 'move', from: origin, amount: 1, elite: 0, ...splitLocation(destination) };
let beforeMove = structuredClone(game);
let tooFar: Action | null = null, alternateMove: Action | null = null;
if (effect === 'kulon') {
tooFar = { type: 'move', from: origin, amount: 1, elite: 0,
...splitLocation(destinationAtRange(game, origin, 3)) };
} else {
game = applySemutaHomeworldChoamStep(game, native.owner, { type: 'endMovement' }, actions);
game = advanceSemutaHomeworldChoam(game,
g => g.phase === 5 && g.active === native.target && clean(g), actions);
alternateMove = { type: 'move', from: origin, amount: 1, elite: 0,
...splitLocation(destinationAtRange(game, origin, 1, [splitLocation(destination).territory])) };
beforeMove = structuredClone(game);
game = applySemutaHomeworldChoamStep(game, native.target, move, actions);
assert.equal(game.decision?.kind, 'choamMovement');
assert.deepEqual(semutaEmperorPlayer(game, native.target).forces, semutaEmperorPlayer(beforeMove, native.target).forces);
assert.equal(semutaEmperorPlayer(game, native.target).moved, 0);
}
const view = viewGame(game, native.owner);
const printed = choamPowerPlays(view, effect).find(play => play.source === 'printed' && play.card.id === card);
assert.ok(printed);
const action = choamPowerAction(view, printed, effect === 'baliset'
? { target: native.target, territory: splitLocation(destination).territory } : {});
assert.ok(action);
return { setup, game, beforeShipment, afterShipment, beforeMove,
owner: native.owner, holder: native.holder, target: effect === 'kulon' ? native.owner : native.target,
canceller: native.canceller, card, effect, action, shipment, move, tooFar, alternateMove,
origin, destination, actions, staging };
}
/** Pass only actual entitled native Worthless cancellation controls. */
export function allowSemutaHomeworldChoamMovement(state: Game, actions?: RicheseSkillsTechStep[]): Game {
let game = state;
for (let count = 0; game.response && count < 32; count++) {
assert.equal(game.response.kind, 'choamWorthless');
const entitled = game.players.find(player => {
const controls = viewGame(game, player.id).responseControls;
return controls && !controls.hasPassed && controls.cancelCards.length > 0;
});
assert.ok(entitled, 'An original entitled cancellation control must allow the power');
game = applySemutaHomeworldChoamStep(game, entitled.id, { type: 'passResponse' }, actions);
}
assert.equal(game.response, null);
return game;
}
export function disposeSemutaHomeworldChoamMovement(fixture: SemutaHomeworldChoamMovementFixture,
state = fixture.game, actions?: RicheseSkillsTechStep[]): Game {
return allowSemutaHomeworldChoamMovement(
applySemutaHomeworldChoamStep(state, fixture.owner, fixture.action, actions), actions);
}
/** Execute a useful original ground move, then finish the original phase by
* native controls. No phase, active player, wallet or custody is reassigned. */
export function finishSemutaHomeworldChoamMovement(state: Game,
fixture: SemutaHomeworldChoamMovementFixture, actions?: RicheseSkillsTechStep[]): Game {
let game = applySemutaHomeworldChoamStep(state, fixture.target,
fixture.effect === 'kulon' ? fixture.move : fixture.alternateMove!, actions);
game = advanceSemutaHomeworldChoam(game, g => g.phase > 5 && clean(g), actions);
assert.equal(game.turn, 1);
return game;
}
export function withoutSemutaHomeworldChoamMovementHolder(state: Game, holder: string): Game {
const game = structuredClone(state), hand = semutaEmperorPlayer(game, holder).hand;
const index = hand.findIndex(card => card.id === SEMUTA_DRUG_ID);
const replacement = game.deck.findIndex(card => card.effect !== 'karama');
assert.ok(index >= 0 && replacement >= 0);
const semuta = hand[index];
hand[index] = game.deck.splice(replacement, 1)[0];
game.deck.push(semuta);
return game;
}
@@ -0,0 +1,90 @@
import assert from 'node:assert/strict';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { choamPowerAction, choamPowerPlays } from '../game/choam-power-options';
import { biddingPhysicalIds } from './fixture-homeworld-occupied-bidding';
import { semutaEmperorPlayer } from './fixture-semuta-emperor-share';
import {
advanceSemutaHomeworldChoam, applySemutaHomeworldChoamStep,
createSemutaHomeworldChoamSetup, holdSemutaHomeworldChoamCard,
} from './fixture-semuta-homeworld-choam';
import type { RicheseSkillsTechStep } from './fixture-richese-skills-tech';
export type SemutaHomeworldChoamReturnPower = 'laLaLa' | 'gamont';
export type SemutaHomeworldChoamReturnsFixture = {
game: Game;
setup: Game;
owner: string;
holder: string;
target: string;
card: string;
power: SemutaHomeworldChoamReturnPower;
action: Action;
from: string;
actions: RicheseSkillsTechStep[];
originalCards: string[];
};
/** Original native setup, first Revival or closing Mentat, no assigned clocks,
* wallets, Tanks, force positions or Homeworld custody. The selected Atreides
* counter is still on its original starting board sector. Only the two physical
* card positions (Semuta and the printed power) are conserved staging. */
export function createSemutaHomeworldChoamReturnsFixture(
power: SemutaHomeworldChoamReturnPower, advanced = false,
): SemutaHomeworldChoamReturnsFixture {
const native = createSemutaHomeworldChoamSetup(advanced);
const { owner, holder, target, actions } = native;
let game = native.game;
const card = holdSemutaHomeworldChoamCard(game, owner,
power === 'laLaLa' ? 'La La La' : 'Trip to Gamont');
const setup = structuredClone(game);
const originalCards = biddingPhysicalIds(game);
const from = Object.keys(semutaEmperorPlayer(setup, target).forces)[0];
assert.ok(from, 'The original Atreides setup has an on-board force');
game = advanceSemutaHomeworldChoam(game, state => power === 'laLaLa'
? state.phase === 4 && !state.phaseOpening && !state.response && !state.decision && !state.pendingTreacheryDiscard
: state.decision?.kind === 'choamMentat', actions);
assert.equal(game.turn, setup.turn, 'Never advance to a replacement source turn');
assert.equal(game.phase, power === 'laLaLa' ? 4 : 8);
assert.deepEqual(semutaEmperorPlayer(game, target).forces,
semutaEmperorPlayer(setup, target).forces, 'The recalled force is original, not a staged shipment or casualty');
assert.deepEqual(biddingPhysicalIds(game), originalCards);
if (power === 'gamont') {
assert.equal(game.decision!.player, owner);
assert.equal(game.choamMentatPending, true);
}
const view = viewGame(game, owner);
const play = choamPowerPlays(view, power).find(play => play.source === 'printed' && play.card.id === card);
assert.ok(play);
const action = choamPowerAction(view, play, { target, ...(power === 'gamont' ? { from, elite: 0 } : {}) });
assert.ok(action, 'The original private controls admit the printed power');
return { game, setup, owner, holder, target, card, power, action, from, actions, originalCards };
}
/** Complete the real Karama response queue, stopping at the cost offer. */
export function playSemutaHomeworldChoamReturns(
fixture: SemutaHomeworldChoamReturnsFixture, state = fixture.game,
): Game {
let game = applySemutaHomeworldChoamStep(state, fixture.owner, fixture.action);
for (let n = 0; game.response?.kind === 'choamWorthless' && n < game.players.length; n++) {
const actor = game.players.find(player => !game.response!.passed.includes(player.id))!.id;
game = applyAction(game, actor, { type: 'passResponse' });
}
assert.notEqual(game.response?.kind, 'choamWorthless');
return game;
}
export function semutaHomeworldChoamReturnsForces(game: Game) {
const worlds = viewGame(game, game.host).homeworlds!.worlds!;
return game.players.map(player => {
const away = worlds.filter(home => home.native !== player.id)
.map(home => home.forces[player.id]).filter(Boolean);
return {
id: player.id,
total: player.reserves + player.tanks + Object.values(player.forces).reduce((sum, n) => sum + n, 0)
+ away.reduce((sum, group) => sum + group.normal + group.elite, 0),
elite: (player.elites?.reserves ?? 0) + (player.elites?.tanks ?? 0)
+ Object.values(player.elites?.forces ?? {}).reduce((sum, n) => sum + n, 0)
+ away.reduce((sum, group) => sum + group.elite, 0),
};
});
}
+66
View File
@@ -0,0 +1,66 @@
import assert from 'node:assert/strict';
import { applyAction, handLimit, type Action, type Game } from '../game/engine';
import { treacheryDeck } from '../game/cards';
import { richeseCards } from '../game/richese-cards';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import { createSemutaKaitainFixture } from './fixture-semuta-kaitain';
import { nextRicheseSkillsTechNativeStep, type RicheseSkillsTechStep } from './fixture-richese-skills-tech';
import { moveSemutaEmperorCard, semutaEmperorPlayer } from './fixture-semuta-emperor-share';
export function createSemutaHomeworldChoamSetup(advanced = false) {
const native = createSemutaKaitainFixture({ advanced, count: 1 });
// This exposed snapshot is the original completed native setup, before that
// fixture's later Kaitain hand staging or paid disposal. Not a clock rewind.
const game = structuredClone(native.setup);
assert.equal(game.status, 'playing');
assert.ok(game.homeworlds?.custody && game.semutaPreview);
assert.ok(game.players.some(p => p.faction === 'choam'));
moveSemutaEmperorCard(game, SEMUTA_DRUG_ID, native.holder);
assert.ok(semutaEmperorPlayer(game, native.holder).hand.length <= handLimit(semutaEmperorPlayer(game, native.holder)));
return { game, owner: native.choam, holder: native.holder,
target: game.players.find(p => p.faction === 'atreides')!.id,
canceller: native.owner, actions: [] as RicheseSkillsTechStep[] };
}
/** Conserved physical card only; never mint a face or rewrite the whole deck. */
export function holdSemutaHomeworldChoamCard(game: Game, owner: string, name: string): string {
const card = [...treacheryDeck(game.expansions), ...richeseCards()].find(card => card.name === name);
assert.ok(card, `Missing printed card ${name}`);
return moveSemutaEmperorCard(game, card.id, owner);
}
/** Caller tests the requested boundary before each real native action. */
export function advanceSemutaHomeworldChoam(
state: Game, until: (game: Game) => boolean, actions?: RicheseSkillsTechStep[],
): Game {
let game = state;
for (let n = 0; !until(game) && n < 1600; n++) {
assert.notEqual(game.status, 'finished', 'The original Homeworld game must reach the requested boundary');
let next: RicheseSkillsTechStep;
const pending = game.pendingTreacheryDiscard, reaction = pending?.reaction;
if (pending && reaction?.stage === 'offer') {
const actor = game.players.find(p => !reaction.passed.includes(p.id))!.id;
next = { actor, action: { type: 'semutaPass', event: pending.batch.event } };
} else if (game.decision?.kind === 'choamMarket') {
next = { actor: game.decision.player, action: { type: 'decision', done: true } };
} else next = nextRicheseSkillsTechNativeStep(game);
actions?.push(next);
game = applyAction(game, next.actor, next.action);
}
assert.ok(until(game), 'The native Homeworld continuation must reach its boundary');
return game;
}
export function reactSemutaHomeworldChoam(state: Game, holder: string, claim: boolean): Game {
const event = state.pendingTreacheryDiscard!.batch.event;
if (claim) return applyAction(state, holder, { type: 'semutaCommit', event });
let game = state;
for (const player of state.players) game = applyAction(game, player.id, { type: 'semutaPass', event });
return game;
}
export function applySemutaHomeworldChoamStep(game: Game, actor: string, action: Action,
actions?: RicheseSkillsTechStep[]): Game {
actions?.push({ actor, action });
return applyAction(game, actor, action);
}
@@ -0,0 +1,159 @@
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 { splitLocation } from '../game/board';
import { advanceSemutaHomeworldChoam, reactSemutaHomeworldChoam } from './fixture-semuta-homeworld-choam';
import { semutaEmperorPlayer as player } from './fixture-semuta-emperor-share';
import {
createSemutaHomeworldChoamMovementFixture, disposeSemutaHomeworldChoamMovement,
finishSemutaHomeworldChoamMovement,
semutaHomeworldChoamMovementResources as resources, semutaHomeworldChoamMovementStock as stock,
withoutSemutaHomeworldChoamMovementHolder,
type SemutaHomeworldChoamMovementEffect,
} from './fixture-semuta-homeworld-choam-movement';
const effects: SemutaHomeworldChoamMovementEffect[] = ['kulon', 'baliset'];
for (const advanced of [false, true]) for (const effect of effects) {
const label = `${advanced ? 'Advanced' : 'Basic'} Homeworld ${effect}`;
for (const claim of [false, true]) void test(`${label}: Semuta ${claim ? 'claim' : 'pass'} preserves paid native shipment and applies the printed movement power once`, () => {
const f = createSemutaHomeworldChoamMovementFixture(effect, advanced);
const inventory = stock(f.game), paid = resources(f.game);
assert.equal(player(f.afterShipment, f.owner).spice, player(f.beforeShipment, f.owner).spice - 2,
'One ordinary off-planet force into desert pays exactly two spice');
assert.equal(player(f.afterShipment, f.owner).reserves, player(f.beforeShipment, f.owner).reserves - 1);
assert.equal(player(f.afterShipment, f.owner).forces[effect === 'kulon' ? f.origin : f.destination], 1);
assert.deepEqual(f.afterShipment.homeworlds?.custody, f.setup.homeworlds?.custody);
if (effect === 'kulon') assert.throws(() => applyAction(f.game, f.owner, f.move));
else {
assert.equal(f.game.decision?.kind, 'choamMovement');
assert.deepEqual(player(f.game, f.target).forces, player(f.beforeMove, f.target).forces);
assert.equal(player(f.game, f.target).moved, 0, 'Declared move remains wholly uncommitted');
}
const offered = disposeSemutaHomeworldChoamMovement(f);
assert.equal(viewGame(offered, f.holder).semutaReaction?.canCommit, true);
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(resources(offered), paid, 'Paid prefix, original force custody and wallets are frozen during the offer');
assert.deepEqual(stock(offered), inventory);
assert.deepEqual(normalizeAutomaticGame(structuredClone(offered)), offered);
assert.throws(() => applyAction(offered, f.target, f.move));
assert.equal(viewGame(offered, f.owner).choamMovementBonus, 0);
assert.deepEqual(viewGame(offered, f.target).balisetRestrictions, []);
const event = offered.pendingTreacheryDiscard!.batch.event;
const resumed = reactSemutaHomeworldChoam(structuredClone(offered), f.holder, claim);
assert.equal(resumed.pendingTreacheryDiscard, null);
assert.deepEqual(resources(resumed), paid);
assert.deepEqual(stock(resumed), inventory);
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.throws(() => applyAction(resumed, f.holder, { type: 'semutaCommit', event }));
if (effect === 'kulon') {
assert.equal(viewGame(resumed, f.owner).choamMovementBonus, 1);
assert.throws(() => applyAction(resumed, f.owner, f.tooFar!),
'Exactly one extra range, not two');
const moved = applyAction(resumed, f.owner, f.move);
assert.equal(player(moved, f.owner).moved, 1);
assert.equal(player(moved, f.owner).forces[f.origin] ?? 0, 0);
assert.equal(player(moved, f.owner).forces[f.destination], 1);
assert.throws(() => applyAction(moved, f.owner, f.move),
'Kulon does not grant a second move');
} else {
const restrictions = viewGame(resumed, f.target).balisetRestrictions;
assert.equal(restrictions.length, 1);
assert.equal(restrictions[0].territory, splitLocation(f.destination).territory);
assert.equal(resumed.pendingChoamMove, null);
assert.equal(player(resumed, f.target).moved, 0);
assert.deepEqual(player(resumed, f.target).forces, player(f.beforeMove, f.target).forces);
assert.throws(() => applyAction(resumed, f.target, f.move));
assert.throws(() => applyAction(resumed, f.owner, f.action),
'The used printed physical card cannot apply a second restriction');
}
const completed = finishSemutaHomeworldChoamMovement(resumed, f);
assert.equal(completed.phase, 7, 'Real endMovement and CHOAM phase controls reach Spice Collection');
assert.equal(completed.turn, 1);
assert.equal(player(completed, f.target).moved, 1);
assert.equal(player(completed, f.owner).reserves, player(f.afterShipment, f.owner).reserves,
'A response and its suffix never ship the original reserve twice');
assert.deepEqual(completed.homeworlds?.custody, f.setup.homeworlds?.custody);
assert.deepEqual(stock(completed), inventory);
});
void test(`${label}: singleton exchange is legal at the original full hand limit and entitlement remains private`, () => {
const f = createSemutaHomeworldChoamMovementFixture(effect, advanced, true);
const absent = withoutSemutaHomeworldChoamMovementHolder(f.game, f.holder);
assert.equal(player(f.game, f.holder).hand.length, handLimit(player(f.game, f.holder)));
const offered = disposeSemutaHomeworldChoamMovement(f);
const neutral = disposeSemutaHomeworldChoamMovement(f, absent);
assert.equal(viewGame(offered, f.holder).semutaReaction?.canCommit, true,
'Exchanging physical Semuta for the singleton needs no fifth slot');
assert.equal(viewGame(neutral, f.holder).semutaReaction?.canCommit, false);
for (const seat of offered.players.filter(seat => seat.id !== f.holder)) {
assert.deepEqual(viewGame(offered, seat.id), viewGame(neutral, seat.id),
'An uninvolved player cannot infer whether Richese owns Semuta');
assert.equal(viewGame(offered, seat.id).semutaReaction?.canCommit, false);
assert.throws(() => applyAction(offered, seat.id,
{ type: 'semutaCommit', event: offered.pendingTreacheryDiscard!.batch.event }));
}
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.some(card => card.id === f.card), true);
assert.equal(player(claimed, f.holder).hand.some(card => card.id === SEMUTA_DRUG_ID), false);
assert.deepEqual(stock(claimed), stock(f.game));
assert.equal(player(finishSemutaHomeworldChoamMovement(claimed, f), f.target).moved, 1);
});
void test(`${label}: all four minimal bot profiles legally claim or pass then finish the native move`, () => {
const f = createSemutaHomeworldChoamMovementFixture(effect, advanced);
const offered = disposeSemutaHomeworldChoamMovement(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 neutral 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(player(game, f.holder).hand.some(card => card.id === f.card), action.type === 'semutaCommit');
assert.deepEqual(resources(game), resources(f.game));
const completed = finishSemutaHomeworldChoamMovement(game, f);
assert.equal(player(completed, f.target).moved, 1);
assert.deepEqual(stock(completed), stock(f.game));
}
});
}
for (const advanced of [false, true]) void test(`${advanced ? 'Advanced' : 'Basic'} native Baliset decline and Karama allow the original declared move exactly once`, () => {
const f = createSemutaHomeworldChoamMovementFixture('baliset', advanced);
const declined = applyAction(f.game, f.owner, { type: 'decision', decline: true });
const declared = applyAction(f.game, f.owner, f.action);
const karama = player(declared, f.canceller).hand.find(card => card.name === 'Karama');
assert.ok(karama);
const cancellation = applyAction(declared, f.canceller, { type: 'card', mode: 'cancel', card: karama.id });
const allowed = advanceSemutaHomeworldChoam(cancellation,
game => !game.pendingTreacheryDiscard && !game.response && !game.decision && !game.phaseOpening);
for (const game of [declined, allowed]) {
assert.equal(player(game, f.target).moved, 1);
assert.equal(player(game, f.target).forces[f.origin], player(f.beforeMove, f.target).forces[f.origin] - 1);
assert.equal(player(game, f.target).forces[f.destination], 1);
assert.equal(player(game, f.owner).hand.some(card => card.id === f.card), true);
assert.equal(game.discard.some(card => card.id === f.card), false);
assert.equal(game.pendingChoamMove, null);
assert.deepEqual(viewGame(game, f.target).balisetRestrictions, []);
assert.equal(player(game, f.owner).reserves, player(f.afterShipment, f.owner).reserves);
assert.deepEqual(game.homeworlds?.custody, f.setup.homeworlds?.custody);
assert.deepEqual(stock(game), stock(f.game));
assert.throws(() => applyAction(game, f.owner, { type: 'decision', decline: true }));
assert.throws(() => applyAction(game, f.target, f.move));
}
});
@@ -0,0 +1,162 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, 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 { biddingPhysicalIds } from './fixture-homeworld-occupied-bidding';
import { semutaEmperorPlayer } from './fixture-semuta-emperor-share';
import { advanceSemutaHomeworldChoam, reactSemutaHomeworldChoam } from './fixture-semuta-homeworld-choam';
import {
createSemutaHomeworldChoamReturnsFixture, playSemutaHomeworldChoamReturns,
semutaHomeworldChoamReturnsForces, type SemutaHomeworldChoamReturnsFixture,
} from './fixture-semuta-homeworld-choam-returns';
const wallets = (game: Game) => game.players.map(player => ({ id: player.id, spice: player.spice }));
const counters = (game: Game, id: string) => {
const p = semutaEmperorPlayer(game, id);
return { forces: p.forces, reserves: p.reserves, tanks: p.tanks,
elites: p.elites, revived: p.revived, freeForcesRevived: p.freeForcesRevived, battleLosses: p.battleLosses };
};
const homeForces = (game: Game) => viewGame(game, game.host).homeworlds!.worlds!
.map(home => ({ id: home.id, native: home.native, forces: home.forces }));
/** Shared observable contract: exactly one printed effect follows the recovery,
* and recovery changes card ownership only, not the source's native economics. */
function assertSettled(f: SemutaHomeworldChoamReturnsFixture, done: Game, claim: boolean) {
assert.equal(done.pendingTreacheryDiscard, null);
const holder = semutaEmperorPlayer(done, f.holder);
assert.equal(holder.hand.some(card => card.id === f.card), claim);
assert.equal(holder.hand.some(card => card.id === SEMUTA_DRUG_ID), !claim);
assert.equal(done.discard.filter(card => card.id === f.card).length, Number(!claim));
assert.equal(done.discard.filter(card => card.id === SEMUTA_DRUG_ID).length, Number(claim));
assert.equal(semutaEmperorPlayer(done, f.owner).hand.some(card => card.id === f.card), false);
assert.deepEqual(biddingPhysicalIds(done), f.originalCards);
assert.deepEqual(semutaHomeworldChoamReturnsForces(done), semutaHomeworldChoamReturnsForces(f.setup));
assert.deepEqual(wallets(done), wallets(f.game));
assert.equal(done.turn, f.game.turn);
assert.equal(done.phase, f.game.phase);
for (const player of done.players.filter(player => player.id !== f.target))
assert.deepEqual(counters(done, player.id), counters(f.game, player.id));
if (f.power === 'laLaLa') {
const before = viewGame(f.game, f.target).revival;
const after = viewGame(done, f.target).revival;
assert.ok(before.freeRemaining > 0, 'Original Atreides has a genuine free allowance');
assert.equal(before.freeBlocked, false);
assert.equal(after.freeRemaining, 0, 'The printed power removes the actual free-revival allowance');
assert.equal(after.freeBlocked, true);
assert.equal(after.limit, before.limit, 'Paid revival allowance is not canceled');
assert.equal(viewGame(done, f.owner).choamWorthless!.targets.includes(f.target), false);
for (const player of done.players.filter(player => player.id !== f.target))
assert.equal(viewGame(done, player.id).revival.freeRemaining, viewGame(f.game, player.id).revival.freeRemaining);
assert.deepEqual(counters(done, f.target), counters(f.game, f.target),
'The original Revival has no Tanks: proactive denial must not invent or revive a casualty');
assert.deepEqual(homeForces(done), homeForces(f.game));
} else {
const before = semutaEmperorPlayer(f.game, f.target), after = semutaEmperorPlayer(done, f.target);
assert.equal(after.forces[f.from], before.forces[f.from] - 1);
assert.equal(after.reserves, before.reserves + 1, 'One ORIGINAL board token returns to native reserves');
assert.equal(after.tanks, before.tanks, 'Trip is not a resurrection from Tanks');
assert.equal(after.revived, before.revived);
assert.equal(after.freeForcesRevived, before.freeForcesRevived);
assert.equal(after.battleLosses, before.battleLosses);
assert.deepEqual(after.elites, before.elites);
assert.deepEqual(Object.entries(after.forces).filter(([key]) => key !== f.from),
Object.entries(before.forces).filter(([key]) => key !== f.from));
const expectedHomes = structuredClone(homeForces(f.game));
const nativeHome = expectedHomes.find(home => home.native === f.target)!;
nativeHome.forces[f.target].normal++;
assert.deepEqual(homeForces(done), expectedHomes, 'Public Caladan custody gains exactly that one ordinary token');
assert.equal(done.decision?.kind, 'choamMentat');
assert.equal(done.decision!.player, f.owner, 'The original closing owner resumes before victory');
assert.equal(viewGame(done, f.target).mentatVictoryPending, true);
assert.equal(done.status, 'playing');
assert.throws(() => applyAction(done, f.target, { type: 'decision', done: true }));
}
assert.throws(() => applyAction(done, f.owner, f.action), 'The original printed power cannot replay');
if (claim) assert.throws(() => applyAction(done, f.holder, f.action),
'Recovering a Worthless card does not grant the non-CHOAM holder its faction power');
}
function consumeOriginalSuffix(f: SemutaHomeworldChoamReturnsFixture, state: Game) {
const before = structuredClone(counters(state, f.target));
const homes = homeForces(state);
const next = f.power === 'gamont'
? applyAction(state, f.owner, { type: 'decision', done: true })
: advanceSemutaHomeworldChoam(state, game => game.phase === 5 && !game.phaseOpening && !game.response && !game.decision);
assert.equal(next.turn, f.game.turn + Number(f.power === 'gamont'));
assert.equal(next.phase, f.power === 'gamont' ? 0 : 5);
assert.equal(next.status, 'playing');
assert.deepEqual(counters(next, f.target), before, 'The real next stage must not run the power a second time');
assert.deepEqual(homeForces(next), homes);
assert.deepEqual(biddingPhysicalIds(next), f.originalCards);
if (f.power === 'gamont') {
assert.equal(next.choamMentatPending, false);
assert.throws(() => applyAction(next, f.owner, { type: 'decision', done: true }),
'The original closing decision is consumed once');
}
}
for (const advanced of [false, true]) {
for (const power of ['laLaLa', 'gamont'] as const) {
for (const claim of [false, true]) void test(
`Homeworld ${advanced ? 'Advanced' : 'Basic'} native ${power}: Semuta ${claim ? 'claims' : 'passes'} before the original effect`, () => {
const f = createSemutaHomeworldChoamReturnsFixture(power, advanced);
const pending = playSemutaHomeworldChoamReturns(f);
const cost = pending.pendingTreacheryDiscard;
assert.ok(cost, 'The actual printed disposal pauses before its native effect');
assert.equal(viewGame(pending, f.holder).semutaReaction!.canCommit, true);
assert.equal(pending.discard.filter(card => card.id === f.card).length, 1);
assert.deepEqual(counters(pending, f.target), counters(f.game, f.target));
assert.deepEqual(homeForces(pending), homeForces(f.game));
assert.deepEqual(wallets(pending), wallets(f.game));
assert.equal(viewGame(pending, f.target).revival.freeRemaining, viewGame(f.game, f.target).revival.freeRemaining);
assert.throws(() => applyAction(pending, f.owner, f.action));
assert.throws(() => applyAction(pending, f.owner, { type: 'decision', done: true }),
'Closing/Revival cannot skip the actual recovery window');
assert.throws(() => applyAction(pending, f.target, { type: 'ready' }));
assert.throws(() => applyAction(pending, f.target, { type: 'semutaCommit', event: cost.batch.event }));
const done = reactSemutaHomeworldChoam(pending, f.holder, claim);
assertSettled(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 }));
consumeOriginalSuffix(f, done);
},
);
for (const difficulty of DIFFICULTIES) void test(
`${difficulty} legally completes Homeworld ${advanced ? 'Advanced' : 'Basic'} native ${power} recovery and original controls`, () => {
const f = createSemutaHomeworldChoamReturnsFixture(power, advanced);
let game = playSemutaHomeworldChoamReturns(f);
const event = game.pendingTreacheryDiscard!.batch.event;
const view = viewGame(game, f.holder);
view.players.find(player => player.id === f.holder)!.bot = difficulty;
const action = botActions(view)[0];
assert.ok(action);
assert.ok(action.type === 'semutaCommit' || action.type === 'semutaPass');
game = applyAction(game, f.holder, action);
for (const player of game.players) {
if (game.pendingTreacheryDiscard?.reaction?.stage === 'offer'
&& !game.pendingTreacheryDiscard.reaction.passed.includes(player.id))
game = applyAction(game, player.id, { type: 'semutaPass', event });
}
const claimed = semutaEmperorPlayer(game, f.holder).hand.some(card => card.id === f.card);
assertSettled(f, game, claimed);
if (power === 'gamont') {
const closingView = viewGame(game, f.owner);
closingView.players.find(player => player.id === f.owner)!.bot = difficulty;
const close = botActions(closingView)[0];
assert.ok(close);
const after = applyAction(game, f.owner, close);
assert.equal(after.turn, game.turn + 1);
assert.equal(after.phase, 0);
assert.equal(after.choamMentatPending, false);
assert.deepEqual(counters(after, f.target), counters(game, f.target));
assert.deepEqual(homeForces(after), homeForces(game));
assert.deepEqual(biddingPhysicalIds(after), f.originalCards);
} else consumeOriginalSuffix(f, game);
},
);
}
}