Files
Dune/tests/fixture-semuta-moritani-retention.ts
T

150 lines
9.4 KiB
TypeScript

import assert from 'node:assert/strict';
import { applyAction, createGame, initializeSemutaGameForAudit, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import type { Difficulty } from '../game/bot-profiles';
import type { Card } from '../game/cards';
import { finishSemutaBattleOutcome } from './fixture-semuta-homeworld-choam';
export type SemutaMoritaniStep = { actor: string; action: Action };
export type SemutaMoritaniOptions = { winnerMandatory?: boolean; karamaHolder?: 'r' | 'm' | 'w' | 'l'; semutaHolder?: 'r' | 'l' };
export type SemutaMoritaniFixture = {
game: Game; semuta: string; weapon: string; defense: string;
winnerWeapon: string; winnerDefense: string; winnerHero: string | null;
unrelated: string; karama: string | null; cards: string[];
loserLeader: string; loserStrength: number; winnerLeader: string;
};
export const semutaMoritaniReload = (game: Game): Game => JSON.parse(JSON.stringify(game));
export function semutaMoritaniPlayer(game: Game, id: string) {
const player = game.players.find(p => p.id === id); assert.ok(player); return player;
}
export function semutaMoritaniInventory(game: Game) {
const cards = [...game.deck, ...game.discard, ...(game.richeseCache ?? []), ...game.players.flatMap(p => p.hand)].map(c => c.id).sort();
assert.equal(new Set(cards).size, cards.length); return cards;
}
export function semutaMoritaniResources(game: Game) {
return game.players.map(({ id, spice, reserves, tanks, forces, leaders, ally }) => ({
id, spice, reserves, tanks, forces, leaders, ally,
}));
}
export function semutaMoritaniAct(game: Game, step: SemutaMoritaniStep, actions?: SemutaMoritaniStep[]) {
actions?.push(structuredClone(step)); return applyAction(game, step.actor, step.action);
}
export function semutaMoritaniPolicy(game: Game, actor: string, difficulty: Difficulty) {
const view = viewGame(game, actor), player = view.players.find(p => p.id === actor); assert.ok(player); player.bot = difficulty;
return botActions(view);
}
/** Genuine Basic Richese/Moritani + classic opponents; no optional modules. */
export function initializeSemutaMoritaniRetentionGame(): Game {
let game = createGame('SEMUTAMORITANI', newPlayer('r', 'Richese', 'richese'), false, ['choam', 'ecaz']);
joinGame(game, newPlayer('m', 'Moritani', 'moritani'));
joinGame(game, newPlayer('w', 'Emperor', 'emperor'));
joinGame(game, newPlayer('l', 'Guild', 'guild'));
for (const player of game.players) { player.bot = 'Medium'; player.ready = true; }
game = initializeSemutaGameForAudit(game);
for (let step = 0; game.status === 'setup' && step < 160; step++) {
let next: SemutaMoritaniStep | undefined;
for (const player of game.players) {
const action = botActions(viewGame(game, player.id))[0];
if (action) { next = { actor: player.id, action }; break; }
}
assert.ok(next, 'Native original setup must offer a legal continuation.');
game = semutaMoritaniAct(game, next);
}
assert.equal(game.status, 'playing'); assert.equal(game.phase, 0);
assert.equal(game.advanced, false); assert.deepEqual(game.expansions, ['choam', 'ecaz']);
assert.ok(game.moritaniTerror); assert.equal(game.moritaniAssassinate == null, true);
assert.equal(game.techTokens == null, true); assert.equal(game.leaderSkills == null, true);
assert.equal(game.strongholdCards == null, true); assert.equal(game.homeworlds == null, true);
assert.equal(game.nexusCards?.cards == null, true); assert.equal(game.ecazTreachery == null, true);
return game;
}
/** Later reciprocal alliance and conserved physical battle are explicitly staged,
* not asserted to have arisen naturally in the original setup. */
export function semutaMoritaniRetentionFixture(options: SemutaMoritaniOptions = {}): SemutaMoritaniFixture {
const game = initializeSemutaMoritaniRetentionGame();
game.deck.push(...game.players.flatMap(p => p.hand));
for (const player of game.players) player.hand = [];
const take = (predicate: (card: Card) => boolean): Card => {
for (const zone of [game.deck, game.discard, game.richeseCache!]) {
const index = zone.findIndex(predicate); if (index >= 0) return zone.splice(index, 1)[0];
}
throw Error('Missing physical original fixture card.');
};
const hold = (owner: string, predicate: (card: Card) => boolean) => {
const card = take(predicate); semutaMoritaniPlayer(game, owner).hand.push(card);
return card.id;
};
const semuta = hold(options.semutaHolder ?? 'r', c => c.id === SEMUTA_DRUG_ID);
const weapon = hold('l', c => c.kind === 'projectile'), defense = hold('l', c => c.kind === 'snooper');
const unrelated = hold('l', c => c.kind === 'worthless');
const winnerWeapon = hold('w', c => c.kind === 'projectile'), winnerDefense = hold('w', c => c.kind === 'shield');
const winnerHero = options.winnerMandatory ? hold('w', c => c.kind === 'hero') : null;
const karama = options.karamaHolder ? hold(options.karamaHolder, c => c.effect === 'karama') : null;
for (const player of game.players) {
player.reserves += Object.values(player.forces).reduce((sum, count) => sum + count, 0);
player.forces = {};
assert.equal(player.tanks, 0); assert.equal(player.reserves, 20);
}
for (const id of ['w', 'l']) {
const player = semutaMoritaniPlayer(game, id); player.reserves -= 5; player.forces['arrakeen:10'] = 5;
}
semutaMoritaniPlayer(game, 'm').ally = 'l'; semutaMoritaniPlayer(game, 'l').ally = 'm';
Object.assign(game, { turn: 2, phase: 6, storm: 18, active: 'w', order: ['w', 'l', 'm', 'r'],
ready: [], response: null, decision: null, phaseOpening: null, stormPending: null, movementRemaining: [] });
const winnerLeader = semutaMoritaniPlayer(game, 'w').leaders.reduce((best, leader) => leader.strength > best.strength ? leader : best).id;
const loser = semutaMoritaniPlayer(game, 'l').leaders[0];
return { game, semuta, weapon, defense, winnerWeapon, winnerDefense, winnerHero,
unrelated, karama, cards: semutaMoritaniInventory(game), winnerLeader,
loserLeader: loser.id, loserStrength: loser.strength };
}
export function resolveSemutaMoritaniBattle(f: SemutaMoritaniFixture, actions?: SemutaMoritaniStep[]): Game {
let game = semutaMoritaniAct(f.game, { actor: 'w', action: { type: 'chooseBattle', territory: 'arrakeen', target: 'l' } }, actions);
for (let step = 0; step < 40; step++) {
if (game.response) game = semutaMoritaniAct(game, { actor: game.players.find(p => !game.response!.passed.includes(p.id))!.id, action: { type: 'passResponse' } }, actions);
else if (game.battle?.preparation) game = semutaMoritaniAct(game, { actor: game.battle.preparation.owner, action: { type: 'declineBattlePower' } }, actions);
else if (game.decision?.kind === 'fullPlanOffer') game = semutaMoritaniAct(game, { actor: game.decision.player, action: { type: 'decision', decline: true } }, actions);
else break;
}
assert.ok(game.battle?.preLeader);
for (const actor of ['w', 'l']) game = semutaMoritaniAct(game, { actor, action: { type: 'battlePreparationReady', event: game.battle!.preLeader!.event } }, actions);
game = semutaMoritaniAct(game, { actor: 'w', action: { type: 'battlePlan', dial: 2, support: 0,
leader: f.winnerHero ?? f.winnerLeader, weapon: f.winnerWeapon, defense: f.winnerDefense } }, actions);
game = semutaMoritaniAct(game, { actor: 'l', action: { type: 'battlePlan', dial: 0, support: 0,
leader: f.loserLeader, weapon: f.weapon, defense: f.defense } }, actions);
for (const actor of ['w', 'l']) game = semutaMoritaniAct(game, { actor, action: { type: 'traitorCall', call: false } }, actions);
return finishSemutaBattleOutcome(game, actions);
}
export function passSemutaMoritaniDiscard(game: Game, actions?: SemutaMoritaniStep[]): Game {
const event = game.pendingTreacheryDiscard!.batch.event;
assert.equal(game.pendingTreacheryDiscard?.reaction?.stage, 'offer');
for (const actor of game.players.map(p => p.id)) game = semutaMoritaniAct(game, { actor, action: { type: 'semutaPass', event } }, actions);
return game;
}
export function claimSemutaMoritaniDiscard(game: Game, card?: string, actions?: SemutaMoritaniStep[]): Game {
const event = game.pendingTreacheryDiscard!.batch.event;
game = semutaMoritaniAct(game, { actor: 'r', action: { type: 'semutaCommit', event } }, actions);
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select') {
assert.ok(card, 'A multiple fresh-card batch needs an exact committed selection.');
game = semutaMoritaniAct(game, { actor: 'r', action: { type: 'semutaSelect', event, card } }, actions);
}
return game;
}
export function finishSemutaMoritaniWinner(game: Game, discard: string[] = [], actions?: SemutaMoritaniStep[]): Game {
assert.equal(game.decision?.kind, 'battleCards');
return semutaMoritaniAct(game, { actor: 'w', action: { type: 'decision', discard } }, actions);
}
export function chooseSemutaMoritaniRetention(game: Game, keep: string | null, actions?: SemutaMoritaniStep[]): Game {
assert.equal(game.decision?.kind, 'moritaniRetention');
return semutaMoritaniAct(game, { actor: 'l', action: { type: 'decision', keep } }, actions);
}
export function allowSemutaMoritaniResponse(game: Game, actions?: SemutaMoritaniStep[]): Game {
for (let step = 0; game.response && step < 40; step++) {
game = semutaMoritaniAct(game, { actor: game.players.find(p => !game.response!.passed.includes(p.id))!.id, action: { type: 'passResponse' } }, actions);
}
assert.equal(game.response, null); return game;
}