Files
Dune/tests/fixture-semuta-skill-aftermath.ts

204 lines
11 KiB
TypeScript

import assert from 'node:assert/strict';
import { applyAction, type Action, type Game, type Player } from '../game/engine';
import type { Card } from '../game/cards';
import { validateLeaderSkills } from '../game/leader-skills';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import { initializeSemutaSkillsGame, swapSemutaSkill } from './fixture-semuta-skills';
import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
export type SemutaSkillAftermathStep = { actor: string; action: Action };
export type SemutaSkillAftermathOptions = {
skill?: 'suk-graduate' | 'rihani-decipherer';
advanced?: boolean;
tech?: boolean;
strongholds?: boolean;
};
export type SemutaSkillAftermathFixture = {
beforeBattle: Game;
game: Game;
actions: SemutaSkillAftermathStep[];
/** Direct replay from beforeBattle to the active mandatory loser offer. */
battleActions: SemutaSkillAftermathStep[];
staging: string[];
inventory: string[];
skill: 'suk-graduate' | 'rihani-decipherer';
owner: string;
opponent: string;
holder: string;
trainer: string;
losingLeader: string;
key: string;
weapon: string;
defense: string;
losingCard: string;
};
export const reloadSemutaSkillAftermath = (game: Game): Game => JSON.parse(JSON.stringify(game));
export function semutaSkillAftermathPlayer(game: Game, actor: string): Player {
const player = game.players.find(p => p.id === actor);
assert.ok(player, `Missing native skill seat ${actor}`);
return player;
}
export function semutaSkillAftermathInventory(game: Game): string[] {
const cards = [...game.deck, ...game.discard, ...(game.richeseCache ?? []),
...(game.richeseRemoved ?? []), ...game.players.flatMap(p => p.hand)];
const ids = cards.map(c => c.id).sort();
assert.equal(new Set(ids).size, ids.length);
return ids;
}
export function assertSemutaSkillAftermathComponents(game: Game, inventory: string[]): void {
assert.deepEqual(semutaSkillAftermathInventory(game), inventory);
validateLeaderSkills(game.leaderSkills!, game.players);
for (const player of game.players) {
assert.equal(player.reserves + player.tanks + Object.values(player.forces).reduce((sum, n) => sum + n, 0), 20);
if (player.elites) {
assert.equal(player.elites.reserves + player.elites.tanks + Object.values(player.elites.forces).reduce((sum, n) => sum + n, 0), 5);
for (const [key, amount] of Object.entries(player.elites.forces)) assert.ok(amount <= (player.forces[key] ?? 0));
}
}
}
/** Human no-spend/no-call policy at original controls; never manufactures a window. */
export function nextSemutaSkillAftermathStep(game: Game): SemutaSkillAftermathStep | null {
if (game.pendingTreacheryDiscard?.reaction || game.decision?.kind === 'sukRescue' || game.decision?.kind === 'rihani') return null;
const decision = game.decision;
if (!game.phaseOpening && !game.response && decision?.kind === 'leaderSkillVisibility')
return { actor: decision.player, action: { type: 'leaderSkillVisibility', event: decision.event, hide: true } };
if (!game.phaseOpening && !game.response && decision?.kind === 'mentatQuestion')
return { actor: decision.player, action: { type: 'decision', event: decision.event, decline: true } };
return nextRicheseStrongholdsNativeStep(game);
}
function advance(game: Game, until: (game: Game) => boolean, actions: SemutaSkillAftermathStep[]): Game {
for (let count = 0; count < 1200; count++) {
if (until(game)) return game;
assert.equal(game.turn, 1);
const next = nextSemutaSkillAftermathStep(game);
assert.ok(next, 'Stop at an original human skill, Semuta or private-plan control');
actions.push(next);
game = applyAction(game, next.actor, next.action);
}
throw new Error('Native Semuta skill aftermath did not reach its requested control');
}
function take(game: Game, actor: string, matches: (card: Card) => boolean, staging: string[]): string {
for (const [source, zone] of [['deck', game.deck], ['discard', game.discard], ['Richese cache', game.richeseCache ?? []],
...game.players.map(p => [p.id, p.hand] as const)] as const) {
const index = zone.findIndex(matches);
if (index < 0) continue;
const card = zone.splice(index, 1)[0];
semutaSkillAftermathPlayer(game, actor).hand.push(card);
staging.push(`Conserved physical ${card.id}: ${source} → ${actor} hand`);
return card.id;
}
throw new Error('Required original physical Treachery Card is unavailable');
}
/** Genuine native all14 setup and training, then actual first Storm/Bidding/Movement.
* Only skill identity, unplayed Spice Card order and conserved physical positions
* are staged. Phases, wallets, leader strength, outcomes and module owners are not. */
export function createSemutaSkillAftermathFixture(options: SemutaSkillAftermathOptions = {}): SemutaSkillAftermathFixture {
const skill = options.skill ?? 'suk-graduate', owner = 'e', opponent = 'a', holder = 'r';
const advanced = options.advanced ?? false, tech = options.tech ?? false, strongholds = options.strongholds ?? false;
assert.ok(advanced || !strongholds);
let game = initializeSemutaSkillsGame(advanced, tech, strongholds);
const actions: SemutaSkillAftermathStep[] = [], staging: string[] = [];
const trainer = swapSemutaSkill(game, owner, skill);
staging.push(`Conserved ${skill} exchanged into ${owner}'s genuinely trained living ${trainer}; original assignment/disc retained`);
const inventory = semutaSkillAftermathInventory(game);
// Prevent an incidental worm/alliance program; every unplayed card stays in its deck.
for (const [position, card] of game.spiceDeck.filter(c => 'territory' in c).slice(0, 2).entries())
game.spiceDeck.splice(position, 0, game.spiceDeck.splice(game.spiceDeck.indexOf(card), 1)[0]);
staging.push('Conserved first two unplayed non-worm Spice Cards ordered first in native deck');
const clean = (state: Game) => !state.phaseOpening && !state.response && !state.decision;
game = advance(game, state => state.phase === 5 && clean(state), actions);
for (const player of game.players) {
game.deck.push(...player.hand); player.hand = [];
player.reserves += Object.values(player.forces).reduce((sum, n) => sum + n, 0); player.forces = {};
if (player.elites) {
player.elites.reserves += Object.values(player.elites.forces).reduce((sum, n) => sum + n, 0);
player.elites.forces = {};
}
}
const key = 'arrakeen:10';
assert.notEqual(game.storm, 10);
for (const actor of [owner, opponent]) {
const player = semutaSkillAftermathPlayer(game, actor);
assert.ok(player.reserves >= 8);
player.reserves -= 8; player.forces[key] = 8;
}
const emperor = semutaSkillAftermathPlayer(game, owner);
if (advanced) {
assert.ok(emperor.elites && emperor.elites.reserves >= 2);
emperor.elites.reserves -= 2; emperor.elites.forces[key] = 2;
}
staging.push(advanced
? 'Conserved board through own reserves; Emperor stages six ordinary/two elite and Atreides eight ordinary at Arrakeen'
: 'Conserved board through own reserves; both Basic combatants stage eight ordinary counters at Arrakeen');
game = advance(game, state => state.phase === 6 && clean(state), actions);
const weapon = take(game, owner, c => c.kind === (skill === 'suk-graduate' ? 'projectile' : 'artillery'), staging);
const defense = take(game, owner, c => c.kind === 'shield', staging);
const losingCard = take(game, opponent, c => c.kind === 'worthless', staging);
take(game, holder, c => c.id === SEMUTA_DRUG_ID, staging);
const assigned = new Set(game.leaderSkills!.assignments.map(a => a.leader));
const losingLeader = semutaSkillAftermathPlayer(game, opponent).leaders.filter(l => !assigned.has(l.id) && !l.dead && !l.usedAt)
.sort((a, b) => a.strength - b.strength)[0];
assert.ok(losingLeader);
assertSemutaSkillAftermathComponents(game, inventory);
const beforeBattle = structuredClone(game);
const battleStart = actions.length;
assert.ok(game.active === owner || game.active === opponent);
const choose: SemutaSkillAftermathStep = { actor: game.active!, action: { type: 'chooseBattle', territory: 'arrakeen', target: game.active === owner ? opponent : owner } };
actions.push(choose); game = applyAction(game, choose.actor, choose.action);
game = advance(game, state => !!state.battle && !state.battle.preparation && state.battle.preLeader?.closed === true && clean(state), actions);
for (const next of [
{ actor: owner, action: { type: 'battlePlan', leader: trainer, dial: 4, support: advanced ? 4 : 0, weapon, defense } },
{ actor: opponent, action: { type: 'battlePlan', leader: losingLeader.id, dial: 0, support: 0, weapon: losingCard } },
] satisfies SemutaSkillAftermathStep[]) {
actions.push(next); game = applyAction(game, next.actor, next.action);
if (game.response || game.decision?.kind === 'fullPlanOffer')
game = advance(game, state => !state.response && state.decision?.kind !== 'fullPlanOffer', actions);
}
game = advance(game, state => state.pendingTreacheryDiscard?.reaction?.stage === 'offer', actions);
assert.equal(game.pendingTreacheryDiscard!.continuation.kind, 'battleResolved');
assert.equal(game.lastBattleContext!.winner, owner);
return { beforeBattle, game, actions, battleActions: actions.slice(battleStart),
staging, inventory, skill, owner, opponent, holder,
trainer, losingLeader: losingLeader.id, key, weapon, defense, losingCard };
}
export function passSemutaSkillAftermath(game: Game): Game {
const pending = game.pendingTreacheryDiscard;
assert.equal(pending?.reaction?.stage, 'offer');
const event = pending!.batch.event;
const reaction = pending!.reaction; assert.ok(reaction?.stage === 'offer');
for (const player of game.players) if (!reaction.passed.includes(player.id))
game = applyAction(game, player.id, { type: 'semutaPass', event });
return game;
}
export function claimSemutaSkillAftermath(game: Game, holder: string, card: string): Game {
const event = game.pendingTreacheryDiscard!.batch.event;
assert.ok(game.pendingTreacheryDiscard!.batch.entries.some(entry => entry.card.id === card));
game = applyAction(game, holder, { type: 'semutaCommit', event });
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select')
game = applyAction(reloadSemutaSkillAftermath(game), holder, { type: 'semutaSelect', event, card });
assert.ok(semutaSkillAftermathPlayer(game, holder).hand.some(held => held.id === card));
return game;
}
/** Select the original typed four-ordinary/zero-elite casualty allocation. */
export function allocateSemutaSkillAftermathLosses(game: Game): Game {
if (game.decision?.kind !== 'battleLosses') return game;
const choice = game.decision.options.findIndex(o => o.normal === 4 && o.elite === 0);
assert.ok(choice >= 0);
return applyAction(game, game.decision.player, { type: 'decision', choice });
}
/** Complete only original post-skill winner cleanup/reward, not Collection. */
export function finishSemutaSkillAftermath(game: Game): Game {
for (let count = 0; count < 100; count++) {
if (!game.pendingTreacheryDiscard && !game.decision && !game.response && !game.battle) return game;
if (game.pendingTreacheryDiscard?.reaction) game = passSemutaSkillAftermath(game);
else {
const next = nextSemutaSkillAftermathStep(game); assert.ok(next);
game = applyAction(game, next.actor, next.action);
}
}
throw new Error('Original post-skill cleanup did not complete');
}