Compose clean Homeworld preparation and battle chooser costs with Semuta
This commit is contained in:
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 };
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user