Files
Dune/tests/fixture-semuta-homeworld-battle-sapho.ts

199 lines
12 KiB
TypeScript

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 };
}