Compose clean Homeworld preparation and battle chooser costs with Semuta

This commit is contained in:
Quintine committed 2026-10-13 05:09:00 +11:00
1 parent fbf77d0f80
commit 8bc7f7aca2
12 files changed
+953 -2

No files matched your search

@@ -0,0 +1,198 @@
import assert from 'node:assert/strict';
import { handLimit, viewGame, type Action, type Game } from '../game/engine';
import { nativeShipmentSources } from '../game/homeworld-options';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import { semutaEmperorPlayer as player, moveSemutaEmperorCard } 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 SemutaHomeworldBattleSaphoFixture = {
scope: 'aggressor' | 'order'; mode: 'first' | 'last'; advanced: boolean;
setup: Game; beforeBattle: Game; game: Game;
choam: string; holder: string; emperor: string; atreides: string; owner: string; actor: string;
action: Action; card: string; location: string; actions: RicheseSkillsTechStep[]; staging: string[];
shipments: { actor: string; action: Action; before: Game; after: Game; price: number }[];
completedPrefix?: { before: Game; after: Game; event: string; actions: RicheseSkillsTechStep[];
richeseLeader: string; atreidesLeader: string; reinforcementEvent: string };
};
const clean = (g: Game) => !g.phaseOpening && !g.response && !g.decision && !g.pendingTreacheryDiscard;
/** Reach the requested native boundary, answering each real gate with its own
* entitled seat's original spending-free control and declining the winner's own
* native Caladan reinforcement instead of letting a generic bot spend. */
function advance(state: Game, until: (game: Game) => boolean, actions: RicheseSkillsTechStep[]): Game {
let game = state;
const stop = (g: Game) => until(g) || !!g.biddingEnd || (g.phase === 5 && clean(g));
for (let n = 0; !until(game) && n < 1600; n++) {
assert.notEqual(game.status, 'finished');
const end = game.biddingEnd;
const seat = end && clean(game) ? end.owners.find(id => !end.ready.includes(id)) : undefined;
if (seat) game = applySemutaHomeworldChoamStep(game, seat,
{ type: 'biddingEnd', event: end!.event, mode: 'ready' }, actions);
else if (game.decision?.kind === 'caladanReinforcement')
game = applySemutaHomeworldChoamStep(game, game.decision.player,
{ type: 'decision', event: game.decision.event, decline: true }, actions);
else if (game.phase === 5 && clean(game))
game = applySemutaHomeworldChoamStep(game, game.active!, { type: 'endMovement' }, actions);
else game = advanceSemutaHomeworldChoam(game,
g => stop(g) || g.decision?.kind === 'caladanReinforcement', actions);
}
assert.ok(until(game), 'Native continuation must reach the requested boundary');
return game;
}
/** Real shared preparation: podium, readiness, optional powers, pre-leader close. */
export function prepareSemutaHomeworldBattleSapho(state: Game, actions?: RicheseSkillsTechStep[]): Game {
return advanceSemutaHomeworldChoam(state,
g => !!g.battle?.preLeader?.closed && !g.battle.preparation && clean(g), actions);
}
/** Genuine native first turn; only conserved physical hands, unplayed Spice order
* and the real Caladan decline are controlled. Every force, wallet and battle
* control is native. Order-last uses the genuine Emperor as the conserved Sapho
* owner, whose position can legally move behind CHOAM. */
export function createSemutaHomeworldBattleSaphoFixture(
scope: 'aggressor' | 'order', mode: 'first' | 'last' = 'first', advanced = false, fullHolder = false,
): SemutaHomeworldBattleSaphoFixture {
const native = createSemutaHomeworldChoamSetup(advanced);
let game = native.game;
const setup = structuredClone(game), actions = native.actions;
const choam = native.owner, holder = native.holder, emperor = native.canceller, atreides = native.target;
const owner = scope === 'order' && mode === 'last' ? emperor : choam;
const staging = ['Conserved physical Semuta to Richese; use native spending-free end-of-Bidding readiness.'];
const blow = game.spiceDeck.find(c => 'territory' in c && c.territory === 'red_chasm');
const other = game.spiceDeck.find(c => 'territory' in c && c !== blow);
assert.ok(blow && other);
for (const [index, card] of [blow, other].entries())
game.spiceDeck.splice(index, 0, game.spiceDeck.splice(game.spiceDeck.indexOf(card), 1)[0]);
staging.push('Position original Red Chasm eight-spice blow and another non-worm Spice Card.');
const shipments: SemutaHomeworldBattleSaphoFixture['shipments'] = [];
const routes: readonly (readonly [string, number, string, number, number])[] = scope === 'order'
? [[holder, 1, 'arrakeen', 10, 1], [emperor, 1, 'red_chasm', 7, 2], [choam, 4, 'red_chasm', 7, 8]]
: [[emperor, 1, 'red_chasm', 7, 2], [choam, 4, 'red_chasm', 7, 8]];
for (const [actor, amount, territory, sector, price] of routes) {
game = advance(game, g => g.phase === 5 && g.active === actor && clean(g), actions);
const sources = nativeShipmentSources(viewGame(game, actor), amount, 0);
assert.ok(sources);
const action: Action = { type: 'ship', territory, sector, amount, elite: 0,
homeworldSources: sources, allyPayment: 0 };
const before = structuredClone(game);
game = applySemutaHomeworldChoamStep(game, actor, action, actions);
game = advance(game, clean, actions);
assert.equal(player(game, actor).spice, player(before, actor).spice - price);
assert.equal(player(game, actor).reserves, player(before, actor).reserves - amount);
shipments.push({ actor, action, before, after: structuredClone(game), price });
game = applySemutaHomeworldChoamStep(game, actor, { type: 'endMovement' }, actions);
}
game = advance(game, g => g.phase === 6 && clean(g), actions);
const beforeBattle = structuredClone(game);
let completedPrefix: SemutaHomeworldBattleSaphoFixture['completedPrefix'];
if (scope === 'order') {
assert.equal(game.active, holder);
const start = actions.length, before = structuredClone(game);
game = applySemutaHomeworldChoamStep(game, holder,
{ type: 'chooseBattle', territory: 'arrakeen', target: atreides }, actions);
const event = game.battle!.event!;
game = prepareSemutaHomeworldBattleSapho(game, actions);
const richeseLeader = player(game, holder).leaders.filter(l => !l.dead && !l.usedAt)
.sort((a, b) => a.strength - b.strength)[0];
const atreidesLeader = player(game, atreides).leaders.filter(l => !l.dead && !l.usedAt)
.sort((a, b) => b.strength - a.strength)[0];
assert.ok(richeseLeader && atreidesLeader && atreidesLeader.strength > richeseLeader.strength);
for (const [actor, leader] of [[holder, richeseLeader.id], [atreides, atreidesLeader.id]])
game = applySemutaHomeworldChoamStep(game, actor,
{ type: 'battlePlan', dial: 0, support: 0, leader }, actions);
game = advance(game, g => g.phase === 6 && !g.battle && clean(g), actions);
const decline = actions.slice(start).find(step =>
step.action.type === 'decision' && 'decline' in step.action && step.action.decline === true &&
'event' in step.action && typeof step.action.event === 'string');
assert.ok(decline && decline.action.type === 'decision' && 'event' in decline.action &&
typeof decline.action.event === 'string', 'The real Caladan decline must be recorded');
const reinforcementEvent: string = decline.action.event;
assert.equal(player(game, holder).tanks, 1);
assert.equal(player(game, holder).forces['arrakeen:10'] ?? 0, 0);
assert.equal(player(game, atreides).forces['arrakeen:10'], 10);
assert.equal(player(game, atreides).reserves, player(before, atreides).reserves);
completedPrefix = { before, after: structuredClone(game), event,
actions: actions.slice(start), richeseLeader: richeseLeader.id,
atreidesLeader: atreidesLeader.id, reinforcementEvent };
}
const card = holdSemutaHomeworldChoamCard(game, owner, 'Juice of Sapho');
staging.push('Conserve canonical Juice of Sapho to its genuine owner before the next clean opportunity.');
if (fullHolder) {
while (player(game, holder).hand.length < handLimit(player(game, holder))) {
const next = game.deck.find(c => c.id !== SEMUTA_DRUG_ID);
assert.ok(next);
moveSemutaEmperorCard(game, next.id, holder);
}
staging.push('Conserve undealt original cards to fill the native Richese hand limit.');
}
if (scope === 'aggressor') {
assert.equal(game.active, emperor);
game = applySemutaHomeworldChoamStep(game, emperor,
{ type: 'chooseBattle', territory: 'red_chasm', target: choam }, actions);
if (game.decision?.kind === 'fullPlanOffer')
game = applySemutaHomeworldChoamStep(game, game.decision.player,
{ type: 'decision', decline: true }, actions);
assert.equal(game.battle!.attacker, emperor);
assert.equal(game.battle!.defender, choam);
assert.equal(game.battle!.chooser, emperor);
assert.equal(game.battle!.preLeader!.closed, false);
assert.equal(game.battle!.preLeader!.ready.length, 0);
assert.ok(!game.battle!.preparation);
}
for (const seat of game.players) assert.ok(seat.hand.length <= handLimit(seat));
const option = viewGame(game, owner).saphoOptions.find(o => scope === 'aggressor'
? o.scope === 'battleAggressor' : o.scope === 'battleOrder' && o.mode === mode);
assert.ok(option, 'The original native game exposes the exact legal Sapho option');
const action: Action = { type: 'card', card, ...option };
return { scope, mode, advanced, setup, beforeBattle, game, choam, holder, emperor, atreides,
owner, actor: owner, action, card, location: 'red_chasm:7', actions, staging, shipments, completedPrefix };
}
export function disposeSemutaHomeworldBattleSapho(f: SemutaHomeworldBattleSaphoFixture,
state = f.game, actions?: RicheseSkillsTechStep[]): Game {
return applySemutaHomeworldChoamStep(state, f.actor, f.action, actions);
}
/** Real next-chooser selection, shared preparation, zero-dial equal living
* leaders, traitor passes and cleanup, then the one native Collection. */
export function finishSemutaHomeworldBattleSapho(state: Game, f: SemutaHomeworldBattleSaphoFixture,
actions: RicheseSkillsTechStep[] = []) {
let game = state;
if (game.pendingTreacheryDiscard)
for (const seat of game.players) {
if (!game.pendingTreacheryDiscard) break;
game = applySemutaHomeworldChoamStep(game, seat.id,
{ type: 'semutaPass', event: game.pendingTreacheryDiscard.batch.event }, actions);
}
const chooser = game.battle?.chooser ?? game.active;
assert.ok(chooser, 'The original native actor retains the next battle choice');
let attacker = game.battle?.attacker, defender = game.battle?.defender, target: string | null = null;
if (!game.battle) {
const choice = viewGame(game, chooser).battleChoices.find(c => c.chooser === chooser);
assert.ok(choice, 'The native next chooser retains a real physical battle option');
target = choice.attacker === chooser ? choice.defender : choice.attacker;
attacker = choice.attacker;
defender = choice.defender;
game = applySemutaHomeworldChoamStep(game, chooser,
{ type: 'chooseBattle', territory: choice.territory, target }, actions);
}
const opened = structuredClone(game);
game = prepareSemutaHomeworldBattleSapho(game, actions);
const [first, second] = [attacker!, defender!];
const strength = player(game, first).leaders.find(l => !l.dead && !l.usedAt &&
player(game, second).leaders.some(o => !o.dead && !o.usedAt && o.strength === l.strength))!.strength;
for (const id of [first, second]) {
const leader = player(game, id).leaders.find(l => !l.dead && !l.usedAt && l.strength === strength)!;
game = applySemutaHomeworldChoamStep(game, id,
{ type: 'battlePlan', leader: leader.id, dial: 0, support: 0 }, actions);
}
const revealed = structuredClone(game);
game = advance(game, g => !g.battle && clean(g), actions);
const afterBattle = structuredClone(game);
game = advance(game, g => g.phase === 7 && clean(g), actions);
assert.equal(game.turn, state.turn);
return { game, opened, revealed, afterBattle, chooser, attacker, defender, target };
}
@@ -0,0 +1,132 @@
import assert from 'node:assert/strict';
import { handLimit, viewGame, type Action, type Game } from '../game/engine';
import { nativeShipmentSources } from '../game/homeworld-options';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import { semutaEmperorPlayer as player, moveSemutaEmperorCard } 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 SemutaHomeworldResidualPoisonFixture = {
setup: Game; beforeBattle: Game; game: Game; owner: string; holder: string; target: string;
actor: string; chooser: string; card: string; action: Action; event: string; location: string;
projectile: string; shield: string; worthless: string;
actions: RicheseSkillsTechStep[]; staging: string[];
shipments: { actor: string; action: Action; before: Game; after: Game }[];
};
const clean = (g: Game) => !g.phaseOpening && !g.response && !g.decision && !g.pendingTreacheryDiscard;
/** Original spending-free end-of-Bidding readiness, not incidental paid Kaitain
* disposal selected by a generic continuation policy. */
function advance(state: Game, until: (g: Game) => boolean, actions: RicheseSkillsTechStep[]): Game {
let game = state;
for (let n = 0; !until(game) && n < 1600; n++) {
const end = game.biddingEnd;
const actor = end && clean(game) ? end.owners.find(id => !end.ready.includes(id)) : undefined;
if (actor) game = applySemutaHomeworldChoamStep(game, actor,
{ type: 'biddingEnd', event: end!.event, mode: 'ready' }, actions);
else game = advanceSemutaHomeworldChoam(game, g => until(g) || !!g.biddingEnd, actions);
}
assert.ok(until(game));
return game;
}
/** Native first-turn paid C4/E1 deployments. Only original physical cards and
* two unplayed non-worm Spice Cards are positioned; no leader is manufactured. */
export function createSemutaHomeworldResidualPoisonFixture(
advanced = false, fullHolder = false,
): SemutaHomeworldResidualPoisonFixture {
const native = createSemutaHomeworldChoamSetup(advanced);
let game = native.game;
const setup = structuredClone(game), actions = native.actions;
const owner = native.owner, holder = native.holder, target = native.canceller;
const location = 'red_chasm:7';
const staging = ['Conserved original Semuta in Richese hand.',
'Each entitled seat answers original end-of-Bidding with its spending-free ready mode.'];
const blow = game.spiceDeck.find(c => 'territory' in c && c.territory === 'red_chasm');
const other = game.spiceDeck.find(c => 'territory' in c && c !== blow);
assert.ok(blow && other);
for (const [position, card] of [blow, other].entries())
game.spiceDeck.splice(position, 0, game.spiceDeck.splice(game.spiceDeck.indexOf(card), 1)[0]);
staging.push('Position original eight-spice Red Chasm blow and another original non-worm Spice Card.');
const shipments: SemutaHomeworldResidualPoisonFixture['shipments'] = [];
for (const [actor, amount] of [[target, 1], [owner, 4]] as const) {
game = advance(game, g => g.phase === 5 && g.active === actor && clean(g), actions);
assert.equal(game.turn, 1);
const sources = nativeShipmentSources(viewGame(game, actor), amount, 0);
assert.ok(sources);
const action: Action = { type: 'ship', territory: 'red_chasm', sector: 7,
amount, elite: 0, homeworldSources: sources, allyPayment: 0 };
const before = structuredClone(game);
game = applySemutaHomeworldChoamStep(game, actor, action, actions);
game = advance(game, clean, actions);
assert.equal(player(game, actor).spice, player(before, actor).spice - 2 * amount);
assert.equal(player(game, actor).reserves, player(before, actor).reserves - amount);
shipments.push({ actor, action, before, after: structuredClone(game) });
game = applySemutaHomeworldChoamStep(game, actor, { type: 'endMovement' }, actions);
}
game = advance(game, g => g.phase === 6 && clean(g), actions);
const cached = game.richeseCache?.find(c => c.effect === 'residualPoison');
assert.ok(cached, 'Original Richese cache must supply the canonical physical Poison');
const card = moveSemutaEmperorCard(game, cached.id, owner);
const projectile = holdSemutaHomeworldChoamCard(game, owner, 'Crysknife');
const shield = holdSemutaHomeworldChoamCard(game, owner, 'Shield');
const worthless = holdSemutaHomeworldChoamCard(game, target, 'Baliset');
staging.push('Conserve original Richese-cache Residual Poison, Crysknife and Shield to CHOAM; Baliset to Emperor.');
if (fullHolder) {
while (player(game, holder).hand.length < handLimit(player(game, holder))) {
const next = game.deck.find(c => c.id !== SEMUTA_DRUG_ID);
assert.ok(next);
moveSemutaEmperorCard(game, next.id, holder);
}
staging.push('Conserved undealt cards fill Richese to its native four-card limit.');
}
for (const seat of game.players) assert.ok(seat.hand.length <= handLimit(seat));
const beforeBattle = structuredClone(game), chooser = game.active!;
assert.equal(chooser, target, 'Original Emperor actor chooses the native desert battle');
game = applySemutaHomeworldChoamStep(game, chooser,
{ type: 'chooseBattle', territory: 'red_chasm', target: owner }, actions);
game = advance(game, clean, actions);
const battle = game.battle!;
assert.equal(battle.preLeader?.closed, false);
assert.deepEqual(battle.preLeader.ready, []);
assert.deepEqual(battle.plans, {});
assert.ok(!battle.preparation && !battle.prescience && !battle.nexusInspection);
const event = battle.event!;
const action: Action = { type: 'card', card, target, event };
return { setup, beforeBattle, game, owner, holder, target, actor: owner, chooser,
card, action, event, location, projectile, shield, worthless, actions, staging, shipments };
}
export function disposeSemutaHomeworldResidualPoison(f: SemutaHomeworldResidualPoisonFixture,
state = f.game, actions?: RicheseSkillsTechStep[]): Game {
return applySemutaHomeworldChoamStep(state, f.actor, f.action, actions);
}
/** Finish the same battle with currently living native discs and actual supported
* plans, then retain played winner cards and reach original native Collection. */
export function finishSemutaHomeworldResidualPoison(f: SemutaHomeworldResidualPoisonFixture,
state: Game, actions: RicheseSkillsTechStep[] = []) {
let game = advance(state, g => !!g.battle?.preLeader?.closed && clean(g), actions);
assert.equal(game.battle?.event, f.event);
const beforePlans = structuredClone(game);
const winnerLeader = player(game, f.owner).leaders.find(l => !l.dead && (!l.usedAt || l.usedAt === 'red_chasm'));
const loserLeader = player(game, f.target).leaders.find(l => !l.dead && (!l.usedAt || l.usedAt === 'red_chasm'));
assert.ok(winnerLeader && loserLeader);
const plans: RicheseSkillsTechStep[] = [
{ actor: f.owner, action: { type: 'battlePlan', dial: 1, support: game.advanced ? 1 : 0,
leader: winnerLeader.id, weapon: f.projectile, defense: f.shield } },
{ actor: f.target, action: { type: 'battlePlan', dial: 0, support: 0,
leader: loserLeader.id, weapon: f.worthless } },
];
for (const plan of plans) game = applySemutaHomeworldChoamStep(game, plan.actor, plan.action, actions);
game = advance(game, g => g.decision?.kind === 'battleCards', actions);
const afterBattle = structuredClone(game);
assert.equal(game.decision?.player, f.owner);
game = applySemutaHomeworldChoamStep(game, f.owner, { type: 'decision', discard: [] }, actions);
const beforeCollection = advance(game, g => g.decision?.kind === 'choamMarket', actions);
game = advance(beforeCollection, g => g.phase === 7 && clean(g), actions);
assert.equal(game.turn, state.turn);
return { game, beforePlans, afterBattle, beforeCollection, plans,
winnerLeader: winnerLeader.id, loserLeader: loserLeader.id };
}
+271
View File
@@ -0,0 +1,271 @@
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 { reactSemutaHomeworldChoam } from './fixture-semuta-homeworld-choam';
import { semutaEmperorPlayer as player } from './fixture-semuta-emperor-share';
import { withoutSemutaHomeworldChoamMovementHolder } from './fixture-semuta-homeworld-choam-movement';
import {
createSemutaHomeworldBattleSaphoFixture, disposeSemutaHomeworldBattleSapho,
finishSemutaHomeworldBattleSapho,
} from './fixture-semuta-homeworld-battle-sapho';
const combos = [
{ scope: 'aggressor', mode: 'first' },
{ scope: 'order', mode: 'first' },
{ scope: 'order', mode: 'last' },
] as const;
/** Only original force, wallet, leader and tank custody — never the committed
* Sapho card, its discard or the scheduling receipt, which the action changes. */
function custody(game: Game) {
return game.players.map(seat => ({ id: seat.id, spice: seat.spice, reserves: seat.reserves,
tanks: seat.tanks, forces: structuredClone(seat.forces), elites: structuredClone(seat.elites),
leaders: structuredClone(seat.leaders) }));
}
function offerEvent(game: Game): string {
const pending = game.pendingTreacheryDiscard;
assert.ok(pending, 'A fresh public disposal offer must be open');
assert.equal(pending.reaction?.stage, 'offer');
return pending.batch.event;
}
function passOffer(state: Game): Game {
let game = state;
if (!game.pendingTreacheryDiscard) return game;
const event = offerEvent(game);
for (const seat of state.players) {
if (!game.pendingTreacheryDiscard) break;
const reaction = game.pendingTreacheryDiscard.reaction;
assert.ok(reaction?.stage === 'offer');
if (!reaction.passed.includes(seat.id))
game = applyAction(game, seat.id, { type: 'semutaPass', event });
}
return game;
}
for (const advanced of [false, true]) for (const combo of combos) {
const label = `${advanced ? 'Advanced' : 'Basic'} Homeworld Sapho ${combo.scope} ${combo.mode}`;
void test(`${label}: actual paid prefix disposes the exact card and commits its real effect`, () => {
const f = createSemutaHomeworldBattleSaphoFixture(combo.scope, combo.mode, advanced);
for (const shipment of f.shipments) {
const amount = shipment.actor === f.choam ? 4 : 1;
const price = shipment.actor === f.holder ? 1 : 2 * amount;
const destination = shipment.actor === f.holder ? 'arrakeen:10' : f.location;
assert.equal(player(shipment.after, shipment.actor).spice,
player(shipment.before, shipment.actor).spice - price);
assert.equal(player(shipment.after, shipment.actor).reserves,
player(shipment.before, shipment.actor).reserves - amount);
assert.equal(player(shipment.after, shipment.actor).forces[destination], amount);
assert.deepEqual(player(shipment.after, shipment.actor).elites,
player(shipment.before, shipment.actor).elites);
}
const pending = disposeSemutaHomeworldBattleSapho(f);
assert.equal(player(pending, f.owner).hand.some(card => card.id === f.card), false);
assert.equal(pending.discard.filter(card => card.id === f.card).length, 1);
assert.deepEqual(pending.order, f.game.order, 'Physical storm order is never rewritten');
assert.deepEqual(custody(pending), custody(f.game), 'No paid prefix, leader or casualty moves');
if (f.scope === 'aggressor') {
assert.equal(pending.battle!.saphoAggressor!.uses.at(-1)!.player, f.owner);
assert.deepEqual(pending.battleOrder, f.game.battleOrder,
'Early aggression preserves original remaining chooser state');
} else {
assert.ok(!pending.battle, 'A completed prefix does not create the next battle');
assert.equal(pending.battleOrder!.uses.length, 1);
assert.equal(pending.battleOrder!.uses[0].mode, f.mode);
assert.equal(pending.battleOrder!.uses[0].player, f.owner);
assert.deepEqual([...pending.battleOrder!.priority].sort(), [...pending.order].sort());
assert.equal(player(pending, f.holder).tanks, 1, 'The original losing prefix force stays destroyed');
assert.equal(player(pending, f.holder).forces['arrakeen:10'] ?? 0, 0);
assert.equal(player(pending, f.atreides).forces['arrakeen:10'], 10, 'Atreides keeps its untouched ten');
assert.equal(player(pending, f.atreides).reserves, player(f.completedPrefix!.before, f.atreides).reserves);
assert.equal(pending.lastBattleContext?.event, f.completedPrefix!.event);
assert.equal(pending.lastBattleContext?.winner, f.atreides);
assert.equal(pending.lastBattleContext?.territory, 'arrakeen');
}
assert.deepEqual(pending.pendingTreacheryDiscard!.batch.entries.map(entry => entry.card.id), [f.card]);
assert.deepEqual(pending.pendingTreacheryDiscard!.batch.entries[0].publicFace, true);
});
void test(`${label}: the open offer exchanges exactly the physical card or stays with Semuta`, () => {
const f = createSemutaHomeworldBattleSaphoFixture(combo.scope, combo.mode, advanced);
const pending = disposeSemutaHomeworldBattleSapho(f), event = offerEvent(pending);
assert.equal(viewGame(pending, f.holder).semutaReaction?.canCommit, true);
for (const seat of pending.players.filter(seat => seat.id !== f.holder))
assert.equal(viewGame(pending, seat.id).semutaReaction?.canCommit, false);
assert.throws(() => applyAction(pending, f.actor, f.action), 'The accepted disposal cannot replay');
assert.throws(() => applyAction(pending, f.holder,
{ type: 'semutaCommit', event: `${event}-retired` }), 'A stale offer event is rejected');
assert.throws(() => applyAction(pending, f.choam, { type: 'ready' }));
const claimed = reactSemutaHomeworldChoam(structuredClone(pending), f.holder, true);
const passed = reactSemutaHomeworldChoam(structuredClone(pending), f.holder, false);
for (const done of [claimed, passed]) {
assert.equal(done.pendingTreacheryDiscard, null);
assert.deepEqual(custody(done), custody(pending), 'Semuta never repeats the paid prefix or casualties');
assert.deepEqual(done.lastBattleContext, pending.lastBattleContext);
assert.deepEqual(done.order, pending.order);
}
assert.equal(player(claimed, f.holder).hand.filter(card => card.id === f.card).length, 1);
assert.equal(claimed.discard.filter(card => card.id === f.card).length, 0);
assert.equal(claimed.discard.filter(card => card.id === SEMUTA_DRUG_ID).length, 1);
assert.equal(player(passed, f.holder).hand.some(card => card.id === SEMUTA_DRUG_ID), true);
assert.equal(player(passed, f.holder).hand.some(card => card.id === f.card), false);
assert.equal(passed.discard.filter(card => card.id === f.card).length, 1);
for (const done of [claimed, passed])
assert.throws(() => applyAction(done, f.holder, { type: 'semutaCommit', event }));
});
void test(`${label}: a full native hand exchanges atomically and entitlement stays private`, () => {
const f = createSemutaHomeworldBattleSaphoFixture(combo.scope, combo.mode, advanced, true);
assert.equal(player(f.game, f.holder).hand.length, handLimit(player(f.game, f.holder)));
const absent = withoutSemutaHomeworldChoamMovementHolder(f.game, f.holder);
const pending = disposeSemutaHomeworldBattleSapho(f);
const neutral = disposeSemutaHomeworldBattleSapho(f, absent);
assert.equal(viewGame(pending, f.holder).semutaReaction?.canCommit, true);
assert.equal(viewGame(neutral, f.holder).semutaReaction?.canCommit, false);
assert.throws(() => applyAction(neutral, f.holder,
{ type: 'semutaCommit', event: neutral.pendingTreacheryDiscard!.batch.event }));
for (const seat of pending.players.filter(seat => seat.id !== f.holder))
assert.deepEqual(JSON.parse(JSON.stringify(viewGame(pending, seat.id))),
JSON.parse(JSON.stringify(viewGame(neutral, seat.id))),
'Nonholders cannot infer the private entitlement from consumer views');
const claimed = reactSemutaHomeworldChoam(pending, 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(custody(claimed), custody(pending));
});
void test(`${label}: the real next chooser finishes one battle and one native Collection`, () => {
const f = createSemutaHomeworldBattleSaphoFixture(combo.scope, combo.mode, advanced);
const done = passOffer(disposeSemutaHomeworldBattleSapho(f));
assert.deepEqual(done.order, f.game.order);
const result = finishSemutaHomeworldBattleSapho(done, f);
assert.equal(result.game.turn, 1);
assert.equal(result.game.phase, 7);
assert.ok(!result.game.battle && !result.game.decision && !result.game.response &&
!result.game.pendingTreacheryDiscard);
assert.deepEqual(result.game.order, f.game.order, 'The finished phase keeps its physical circle');
assert.equal(result.game.lastBattleContext?.result, 'normal');
assert.equal(result.game.lastBattleContext?.territory, 'red_chasm');
if (f.scope === 'aggressor') {
assert.equal(result.attacker, f.emperor);
assert.equal(result.defender, f.choam);
assert.equal(result.game.lastBattleContext?.winner, f.owner, 'The recorded aggressor takes the tie');
} else {
assert.equal(result.attacker, f.emperor);
assert.equal(result.defender, f.choam);
assert.equal(result.target, result.attacker, 'The chooser names the actual opposing physical slot');
assert.equal(result.game.lastBattleContext?.winner, f.emperor);
if (combo.mode === 'first')
assert.equal(result.chooser, f.owner, 'The first position makes the physical defender C the chooser');
else {
assert.equal(result.chooser, f.choam, 'A last position immunizes nobody from the current chooser');
assert.equal(done.battleOrder!.priority.at(-1), f.owner,
'The Emperor really holds the accepted last chooser position');
}
}
const victor = f.scope === 'aggressor' ? f.owner : f.emperor;
const loser = f.scope === 'aggressor' ? f.emperor : f.choam;
const committed = player(done, loser).forces['red_chasm:7'] ?? 0;
const survivors = player(result.game, victor).forces['red_chasm:7'] ?? 0;
assert.equal(survivors, player(done, victor).forces['red_chasm:7'],
'The winner keeps exactly its untouched committed force');
assert.equal(player(result.game, loser).forces['red_chasm:7'] ?? 0, 0);
assert.equal(player(result.game, loser).tanks, player(done, loser).tanks + committed,
'Battle casualties destroy the loser force once');
assert.equal(player(result.game, victor).tanks, player(done, victor).tanks);
assert.equal(result.game.spice['red_chasm:7'], 8 - 2 * survivors,
'The original eight-spice blow is drained exactly once by its surviving force');
assert.equal(player(result.game, victor).spice, player(done, victor).spice + 2 * survivors,
'Only the survivor collection credits the winner; no phase or receipt repeats');
for (const seat of result.game.players.filter(seat => seat.id !== victor))
assert.equal(seat.forces['red_chasm:7'] ?? 0, 0);
assert.throws(() => applyAction(result.game, f.choam, { type: 'endMovement' }));
});
void test(`${label}: only legacy, stale or readiness-closed Sapho controls survive untouched`, () => {
const f = createSemutaHomeworldBattleSaphoFixture(combo.scope, combo.mode, advanced);
const scope = f.scope === 'aggressor' ? 'battleAggressor' : 'battleOrder';
assert.equal(viewGame(f.game, f.owner).saphoOptions.find(option => option.scope === scope)?.mode,
f.scope === 'aggressor' ? 'aggressor' : f.mode);
const spent = applyAction(f.game, f.actor, f.action);
assert.deepEqual(viewGame(spent, f.owner).saphoOptions, [], 'One card cannot be spent twice');
assert.throws(() => applyAction(spent, f.actor, f.action));
assert.throws(() => applyAction(f.game, f.actor, { ...f.action, event: 'expired' }));
const wrong = f.scope === 'aggressor' ? 'last' : f.mode === 'first' ? 'last' : 'first';
assert.throws(() => applyAction(f.game, f.owner, { ...f.action, mode: wrong }),
'Only the one legal position at this boundary is accepted');
if (f.scope === 'aggressor')
assert.deepEqual(viewGame(applyAction(f.game, f.owner,
{ type: 'battlePreparationReady', event: f.game.battle!.event }), f.owner).saphoOptions, [],
'The owner\'s own readiness closes the early pre-plan aggressor window');
const other = f.game.players.find(seat => ![f.owner, f.holder].includes(seat.id))!.id;
assert.throws(() => applyAction(f.game, other, f.action));
});
void test(`${label}: every minimal bot profile plays the real producer then the native battle`, () => {
for (const difficulty of DIFFICULTIES) {
const f = createSemutaHomeworldBattleSaphoFixture(combo.scope, combo.mode, advanced);
const own = viewGame(structuredClone(f.game), f.owner);
own.players.find(seat => seat.id === f.owner)!.bot = difficulty;
const action = botActions(own).find(proposal => proposal.card === f.card);
assert.ok(action, `${difficulty}: the owner keeps a real Sapho action`);
assert.equal(action.scope, f.scope === 'aggressor' ? 'battleAggressor' : 'battleOrder');
let game = applyAction(f.game, f.owner, action);
game = passOffer(game);
assert.ok(!game.pendingTreacheryDiscard);
const result = finishSemutaHomeworldBattleSapho(game, f);
assert.equal(result.game.phase, 7, `${difficulty}: the real continuation reaches Collection`);
assert.equal(result.game.turn, 1);
}
});
}
const orders = combos.filter(combo => combo.scope === 'order');
for (const advanced of [false, true]) for (const combo of orders) {
const label = `${advanced ? 'Advanced' : 'Basic'} Homeworld Sapho order ${combo.mode}`;
void test(`${label}: finite remaining queue and the exact accepted position, no extra battle`, () => {
const f = createSemutaHomeworldBattleSaphoFixture(combo.scope, combo.mode, advanced);
const pending = disposeSemutaHomeworldBattleSapho(f);
const order = pending.battleOrder!;
const priority = order.priority;
assert.equal(new Set(priority).size, pending.order.length);
assert.deepEqual([...priority].sort(), [...pending.order].sort());
if (combo.mode === 'first') assert.equal(priority.indexOf(f.owner), 0);
else assert.equal(priority.indexOf(f.owner), priority.length - 1);
const view = viewGame(pending, f.owner).battleOrder;
assert.ok(view);
assert.deepEqual(view.remaining, [f.choam, f.emperor],
'Only the two remaining physical combatants retain one chooser opportunity');
assert.equal(view.uses.length, 1);
assert.ok(!pending.battle);
assert.deepEqual(pending.order, f.game.order);
});
}
for (const advanced of [false, true]) void test(`${advanced ? 'Advanced' : 'Basic'} early aggressor retains the opponent's completed readiness through Semuta`, () => {
const f = createSemutaHomeworldBattleSaphoFixture('aggressor', 'first', advanced, true);
const event = f.game.battle!.event!;
const ready = applyAction(f.game, f.emperor, { type: 'battlePreparationReady', event });
const pending = disposeSemutaHomeworldBattleSapho(f, ready);
assert.equal(pending.pendingTreacheryDiscard?.continuation.kind, 'saphoAggressorDiscard');
const done = reactSemutaHomeworldChoam(pending, f.holder, true);
assert.deepEqual(done.battle!.preLeader!.ready, [f.emperor]);
assert.deepEqual(custody(done), custody(ready));
assert.equal(done.battle!.attacker, f.emperor);
assert.equal(done.battle!.defender, f.choam);
assert.equal(done.battle!.chooser, f.emperor);
assert.equal(done.battle!.saphoAggressor!.uses.at(-1)!.player, f.choam);
const prepared = applyAction(done, f.choam, { type: 'battlePreparationReady', event });
assert.equal(prepared.battle!.preLeader!.closed, true);
const result = finishSemutaHomeworldBattleSapho(prepared, f);
assert.equal(result.game.lastBattleContext!.winner, f.choam);
assert.equal(player(result.game, f.choam).forces[f.location], 4);
assert.equal(player(result.game, f.emperor).tanks, 1);
assert.equal(result.game.phase, 7);
});
@@ -0,0 +1,219 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, handLimit, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { quoteSpiceCollection } from '../game/board-resolution-quote';
import { residualPoisonCandidates, residualPoisonDeath } from '../game/residual-poison';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import { semutaEmperorPlayer as player } from './fixture-semuta-emperor-share';
import { reactSemutaHomeworldChoam } from './fixture-semuta-homeworld-choam';
import { withoutSemutaHomeworldChoamMovementHolder } from './fixture-semuta-homeworld-choam-movement';
import { semutaHomeworldWinnerResources as resources, semutaHomeworldWinnerStock as stock } from './fixture-semuta-homeworld-battle-winner';
import { createSemutaHomeworldResidualPoisonFixture, disposeSemutaHomeworldResidualPoison,
finishSemutaHomeworldResidualPoison, type SemutaHomeworldResidualPoisonFixture } from './fixture-semuta-homeworld-residual-poison';
function rejectUnchanged(game: Game, actor: string, action: Action) {
const before = JSON.stringify(game);
assert.throws(() => applyAction(game, actor, action));
assert.equal(JSON.stringify(game), before, 'Rejected actions preserve the original physical game');
}
function assertNativePoison(f: SemutaHomeworldResidualPoisonFixture, game: Game, before = f.game) {
const prior = player(before, f.target).leaders;
const changed = player(game, f.target).leaders.filter(l =>
JSON.stringify(l) !== JSON.stringify(prior.find(old => old.id === l.id)));
assert.equal(changed.length, 1, 'Exactly one original Emperor disc changes at the Poison cost boundary');
const victim = changed[0];
assert.ok(residualPoisonCandidates(prior, 'red_chasm').some(l => l.id === victim.id));
assert.deepEqual(victim, residualPoisonDeath(prior.find(l => l.id === victim.id)!));
const expected = resources(before);
expected.players.find(p => p.id === f.target)!.leaders = prior.map(l =>
l.id === victim.id ? residualPoisonDeath(l) : l);
assert.deepEqual(resources(game), expected,
'Poison awards no spice, loses no counters, and leaves every other leader/custody/resource unchanged');
assert.equal(player(game, f.owner).forces[f.location], 4);
assert.equal(player(game, f.target).forces[f.location], 1);
assert.equal(player(game, f.owner).hand.some(c => c.id === f.card), false);
assert.equal(game.discard.filter(c => c.id === f.card).length, 1);
assert.equal(game.battle?.event, f.event);
assert.deepEqual(game.battle?.plans, {});
assert.deepEqual(stock(game), stock(f.setup));
return victim.id;
}
function assertExchange(f: SemutaHomeworldResidualPoisonFixture, pending: Game, game: Game,
claim: boolean, holderHasSemuta = true) {
assert.ok(!game.pendingTreacheryDiscard);
assert.deepEqual(resources(game), resources(pending), 'Resume neither redraws nor repeats the committed native death');
assert.deepEqual(game.battle, pending.battle, 'The original shared preparation event and readiness resume unchanged');
assert.equal(player(game, f.holder).hand.filter(c => c.id === f.card).length, Number(claim));
assert.equal(game.discard.filter(c => c.id === f.card).length, Number(!claim));
if (holderHasSemuta) {
assert.equal(game.discard.filter(c => c.id === SEMUTA_DRUG_ID).length, Number(claim));
assert.equal(player(game, f.holder).hand.some(c => c.id === SEMUTA_DRUG_ID), !claim);
} else
assert.equal(player(game, f.holder).hand.some(c => c.id === SEMUTA_DRUG_ID), false,
'The absent-holder fork never returns the Semuta Drug to this seat');
for (const seat of game.players) {
const expected = player(pending, seat.id).hand.map(c => c.id)
.filter(id => seat.id !== f.holder || !claim || id !== SEMUTA_DRUG_ID);
if (seat.id === f.holder && claim) expected.push(f.card);
assert.deepEqual(seat.hand.map(c => c.id).sort(), expected.sort());
assert.ok(seat.hand.length <= handLimit(seat));
}
assert.deepEqual(stock(game), stock(f.setup));
}
function finishOnce(f: SemutaHomeworldResidualPoisonFixture, state: Game, victim: string) {
const acceptedVictim = structuredClone(player(state, f.target).leaders.find(l => l.id === victim));
const finished = finishSemutaHomeworldResidualPoison(f, state);
const { beforePlans, afterBattle, beforeCollection, game } = finished;
assert.equal(beforePlans.battle?.event, f.event);
assert.equal(beforePlans.battle?.preLeader?.closed, true);
assert.deepEqual(beforePlans.battle?.preLeader?.ready.slice().sort(), [f.owner, f.target].sort());
assert.notEqual(finished.loserLeader, victim, 'Future native plans select a currently living Emperor disc, never the random victim');
assert.equal(player(beforePlans, f.target).leaders.find(l => l.id === finished.loserLeader)?.dead, false);
assert.equal(afterBattle.lastBattleContext?.winner, f.owner);
assert.equal(afterBattle.lastBattleContext?.result, 'normal');
assert.equal(afterBattle.lastBattleContext?.territory, 'red_chasm');
assert.ok(!afterBattle.battle);
assert.equal(player(afterBattle, f.owner).forces[f.location], 3);
assert.equal(player(afterBattle, f.owner).reserves, 16);
assert.equal(player(afterBattle, f.owner).tanks, player(beforePlans, f.owner).tanks + 1);
assert.equal(player(afterBattle, f.owner).battleLosses, player(beforePlans, f.owner).battleLosses + 1);
assert.equal(player(afterBattle, f.target).forces[f.location] ?? 0, 0);
assert.equal(player(afterBattle, f.target).reserves, 19);
assert.equal(player(afterBattle, f.target).tanks, player(beforePlans, f.target).tanks + 1);
const combatLeader = player(beforePlans, f.target).leaders.find(l => l.id === finished.loserLeader)!;
assert.equal(player(afterBattle, f.target).leaders.find(l => l.id === combatLeader.id)?.dead, true);
assert.equal(player(afterBattle, f.target).leaders.find(l => l.id === combatLeader.id)?.deaths, combatLeader.deaths + 1);
assert.deepEqual(player(afterBattle, f.target).leaders.find(l => l.id === victim), acceptedVictim);
assert.equal(player(afterBattle, f.owner).spice,
player(beforePlans, f.owner).spice - Number(game.advanced) + combatLeader.strength,
'Only actual supported dial cost and the distinct killed combat leader produce battle spice');
assert.equal(player(afterBattle, f.target).spice, player(beforePlans, f.target).spice);
assert.deepEqual(afterBattle.homeworlds?.custody, f.shipments[1].after.homeworlds?.custody);
const collection = quoteSpiceCollection(beforeCollection), expected = resources(beforeCollection);
assert.equal(collection.receipts.find(r => r.player === f.owner)?.desert, 6,
'Three surviving counters collect six from the original eight-spice Red Chasm blow');
for (const receipt of collection.receipts) expected.players.find(p => p.id === receipt.player)!.spice = receipt.balance;
assert.deepEqual(resources(game), expected, 'Native Collection credits legitimate board/stronghold income once');
assert.deepEqual(game.spice, collection.spice);
assert.deepEqual(game.lastBattleContext, afterBattle.lastBattleContext);
assert.deepEqual(player(game, f.target).leaders.find(l => l.id === victim), acceptedVictim);
assert.deepEqual(stock(game), stock(f.setup));
assert.equal(game.phase, 7);
assert.equal(game.turn, 1);
assert.ok(!game.response && !game.decision && !game.pendingTreacheryDiscard);
rejectUnchanged(game, f.owner, { type: 'decision', done: true });
}
for (const advanced of [false, true]) {
const label = `${advanced ? 'Advanced' : 'Basic'} paid Homeworld Residual Poison`;
for (const full of [false, true]) for (const claim of [false, true])
void test(`${label}: ${full ? 'full four-card' : 'thin'} hand ${claim ? 'claim' : 'all-pass'} preserves native death and future battle`, () => {
const f = createSemutaHomeworldResidualPoisonFixture(advanced, full);
assert.equal(f.chooser, f.target);
assert.equal(viewGame(f.game, f.owner).residualPoison?.blocked, null);
assert.equal(viewGame(f.game, f.owner).residualPoison?.target, f.target);
assert.equal(player(f.game, f.holder).hand.length === 4, full);
for (const shipment of f.shipments) {
const amount = shipment.actor === f.target ? 1 : 4;
assert.equal(player(shipment.after, shipment.actor).spice, player(shipment.before, shipment.actor).spice - 2 * amount);
assert.equal(player(shipment.after, shipment.actor).reserves, player(shipment.before, shipment.actor).reserves - amount);
assert.equal(player(shipment.after, shipment.actor).forces[f.location], amount);
assert.deepEqual(player(shipment.after, shipment.actor).elites, player(shipment.before, shipment.actor).elites);
}
rejectUnchanged(f.game, f.holder, f.action);
rejectUnchanged(f.game, f.owner, { ...f.action, target: f.holder });
rejectUnchanged(f.game, f.owner, { ...f.action, event: `${f.event}-stale` });
rejectUnchanged(f.beforeBattle, f.owner, f.action);
const pending = disposeSemutaHomeworldResidualPoison(f);
const victim = assertNativePoison(f, pending);
const event = pending.pendingTreacheryDiscard!.batch.event;
assert.equal(pending.pendingTreacheryDiscard?.continuation.kind, 'residualPoisonDiscard');
assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer');
assert.deepEqual(pending.pendingTreacheryDiscard?.batch.entries.map(e => e.card.id), [f.card]);
assert.equal(viewGame(pending, f.holder).semutaReaction?.canCommit, true);
rejectUnchanged(pending, f.owner, f.action);
rejectUnchanged(pending, f.target, { type: 'battlePreparationReady', event: f.event });
rejectUnchanged(pending, f.target, { type: 'semutaCommit', event });
rejectUnchanged(pending, f.holder, { type: 'semutaCommit', event: `${event}-stale` });
rejectUnchanged(pending, f.holder, { type: 'semutaSelect', event, card: f.shield });
const acknowledged = applyAction(pending, f.target, { type: 'semutaPass', event });
assert.deepEqual(resources(acknowledged), resources(pending));
rejectUnchanged(acknowledged, f.target, { type: 'semutaPass', event });
const done = reactSemutaHomeworldChoam(JSON.parse(JSON.stringify(pending)) as Game, f.holder, claim);
assertExchange(f, pending, done, claim);
assert.deepEqual(player(done, f.target).leaders.find(l => l.id === victim), player(pending, f.target).leaders.find(l => l.id === victim));
rejectUnchanged(done, f.holder, { type: 'semutaCommit', event });
rejectUnchanged(done, f.holder, { type: 'semutaPass', event });
rejectUnchanged(done, f.holder, { type: 'semutaSelect', event, card: f.card });
rejectUnchanged(done, f.target, { type: 'battlePreparationReady', event: `${f.event}-stale` });
const ready = applyAction(done, f.target, { type: 'battlePreparationReady', event: f.event });
assert.deepEqual(ready.battle?.preLeader?.ready, [f.target]);
assert.deepEqual(resources(ready), resources(done));
finishOnce(f, ready, victim);
});
void test(`${label}: full-hand entitlement is private and absent-holder pass changes no death or resource`, () => {
const f = createSemutaHomeworldResidualPoisonFixture(advanced, true);
const absent = withoutSemutaHomeworldChoamMovementHolder(f.game, f.holder);
const pending = disposeSemutaHomeworldResidualPoison(f);
const neutral = disposeSemutaHomeworldResidualPoison(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(p => p.id !== f.holder)) {
assert.deepEqual(JSON.parse(JSON.stringify(viewGame(f.game, seat.id))),
JSON.parse(JSON.stringify(viewGame(absent, seat.id))),
'The conserved private Semuta position is invisible to other seats');
// Native programs can select different victims. Compare neutral offer
// entitlement separately from its opaque event.
const { event: _heldEvent, ...heldOffer } = viewGame(pending, seat.id).semutaReaction!;
const { event: _absentEvent, ...absentOffer } = viewGame(neutral, seat.id).semutaReaction!;
assert.equal(heldOffer.canCommit, false);
assert.deepEqual(heldOffer, absentOffer);
}
const victim = assertNativePoison(f, neutral, absent);
const done = reactSemutaHomeworldChoam(JSON.parse(JSON.stringify(neutral)) as Game, f.holder, false);
assertExchange(f, neutral, done, false, false);
finishOnce(f, done, victim);
});
void test(`${label}: actual opponent readiness permits native Poison but excludes Semuta's clean producer`, () => {
const f = createSemutaHomeworldResidualPoisonFixture(advanced);
const ready = applyAction(f.game, f.target, { type: 'battlePreparationReady', event: f.event });
assert.deepEqual(ready.battle?.preLeader?.ready, [f.target]);
assert.equal(viewGame(ready, f.owner).residualPoison?.blocked, null);
const done = disposeSemutaHomeworldResidualPoison(f, ready);
const victim = assertNativePoison(f, done, ready);
assert.ok(!done.pendingTreacheryDiscard);
assert.ok(!viewGame(done, f.holder).semutaReaction);
assert.deepEqual(done.battle?.preLeader?.ready, [f.target]);
finishOnce(f, done, victim);
});
for (const difficulty of DIFFICULTIES) void test(`${difficulty}: legal minimum ${label} consumer and original future plans`, () => {
const f = createSemutaHomeworldResidualPoisonFixture(advanced);
const pending = disposeSemutaHomeworldResidualPoison(f);
const victim = assertNativePoison(f, pending);
let game = pending;
const own = viewGame(game, f.holder);
own.players.find(p => p.id === f.holder)!.bot = difficulty;
const action = botActions(own)[0];
assert.ok(action && (action.type === 'semutaCommit' || action.type === 'semutaPass'));
game = applyAction(game, f.holder, action);
for (const seat of pending.players) {
if (!game.pendingTreacheryDiscard) break;
const view = viewGame(game, seat.id);
if (!view.semutaReaction || view.semutaReaction.passed) continue;
view.players.find(p => p.id === seat.id)!.bot = difficulty;
const pass = botActions(view)[0];
assert.equal(pass?.type, 'semutaPass');
game = applyAction(game, seat.id, pass);
}
assertExchange(f, pending, game, action.type === 'semutaCommit');
finishOnce(f, game, victim);
});
}