269 lines
13 KiB
TypeScript
269 lines
13 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import { applyAction, normalizeAutomaticGame, viewGame, type Action, type Game } from '../game/engine';
|
|
import { MOBILE_LOCATION, MOBILE_STRONGHOLD } from '../game/board';
|
|
import type { LeaderSkillId } from '../game/leader-skill-cards';
|
|
import {
|
|
completedIxSkillsGame,
|
|
assertIxSkillsCustody as custody,
|
|
ixSkillsPlayer as player,
|
|
reloadIxSkillsGame as reload,
|
|
rejectIxSkillsAction as reject,
|
|
} from './ix-skills-fixture';
|
|
|
|
const act = (game: Game, owner: string, action: Action) => applyAction(reload(game), owner, action);
|
|
function stable(game: Game) {
|
|
custody(game);
|
|
assert.deepEqual(normalizeAutomaticGame(reload(game)), game);
|
|
}
|
|
|
|
/** Conserved battle positions after real setup; these are not claimed played phase histories. */
|
|
function stage(skill: LeaderSkillId, location = 'wind_pass:14') {
|
|
const game = completedIxSkillsGame({ requestedSkill: skill });
|
|
for (const p of game.players) {
|
|
game.deck.push(...p.hand);
|
|
p.hand = [];
|
|
p.forces = p.id === 't' ? {} : { [location]: 8, 'carthag:11': 1 };
|
|
p.reserves = p.id === 't' ? 20 : 11;
|
|
p.tanks = 0;
|
|
p.battleLosses = 0;
|
|
p.spice = 20;
|
|
}
|
|
player(game, 'i').elites = { reserves: 5, tanks: 0, forces: { [location]: 2 }, revived: 0 };
|
|
Object.assign(game, { phase: 6, active: 'i', order: ['i', 'e', 't'], storm: 18,
|
|
ready: [], decision: null, response: null, phaseOpening: null, spice: {} });
|
|
return game;
|
|
}
|
|
|
|
function take(game: Game, owner: string, predicate: (card: Game['deck'][number]) => boolean) {
|
|
const index = game.deck.findIndex(predicate);
|
|
assert.ok(index >= 0);
|
|
const card = game.deck.splice(index, 1)[0];
|
|
player(game, owner).hand.push(card);
|
|
return card;
|
|
}
|
|
|
|
function passResponses(game: Game) {
|
|
for (let step = 0; game.response && step < 20; step++) {
|
|
const p = game.players.find(candidate => !game.response!.passed.includes(candidate.id));
|
|
assert.ok(p);
|
|
game = act(game, p.id, { type: 'passResponse' });
|
|
}
|
|
assert.equal(game.response, null);
|
|
return game;
|
|
}
|
|
|
|
function open(game: Game, territory = 'wind_pass') {
|
|
game = act(game, 'i', { type: 'chooseBattle', territory, target: 'e' });
|
|
for (let step = 0; step < 30; step++) {
|
|
if (game.response) game = passResponses(game);
|
|
else if (game.decision?.kind === 'mentatQuestion')
|
|
game = act(game, game.decision.player, { type: 'decision', event: game.decision.event, decline: true });
|
|
else if (game.decision?.kind === 'leaderSkillVisibility')
|
|
game = act(game, game.decision.player, { type: 'leaderSkillVisibility', event: game.decision.event, hide: true });
|
|
else if (game.battle?.preparation)
|
|
game = act(game, game.battle.preparation.owner, { type: 'declineBattlePower' });
|
|
else break;
|
|
}
|
|
assert.equal(game.decision, null);
|
|
assert.equal(game.battle?.preparation, undefined);
|
|
return game;
|
|
}
|
|
|
|
function resolve(game: Game, own: Partial<Action> = {}, territory = 'wind_pass') {
|
|
game = open(game, territory);
|
|
game = act(game, 'i', { type: 'battlePlan', dial: 6, leader: player(game, 'i').leaders[0].id, ...own });
|
|
game = act(game, 'e', { type: 'battlePlan', dial: 0, leader: player(game, 'e').leaders[4].id });
|
|
game = act(game, 'i', { type: 'traitorCall', call: false });
|
|
game = act(game, 'e', { type: 'traitorCall', call: false });
|
|
if (game.decision?.kind === 'battleLosses') {
|
|
const choice = game.decision.options.findIndex(option => option.normal === 4 && option.elite === 2);
|
|
assert.ok(choice >= 0);
|
|
game = act(game, 'i', { type: 'decision', choice });
|
|
}
|
|
return game;
|
|
}
|
|
|
|
function finishSubstitution(game: Game, outcome: 'allow' | 'cancel' | 'decline', karama?: string) {
|
|
assert.equal(game.decision?.kind, 'ixSubstitution');
|
|
if (outcome === 'decline') return act(game, 'i', { type: 'decision', decline: true });
|
|
game = act(game, 'i', { type: 'decision', sources: { 'wind_pass:14': 1 }, recover: { 'wind_pass:14': 1 } });
|
|
assert.equal(game.response?.kind, 'ixSubstitution');
|
|
stable(game);
|
|
if (outcome === 'cancel') {
|
|
assert.ok(karama);
|
|
return passResponses(act(game, 'e', { type: 'card', card: karama, mode: 'cancel' }));
|
|
}
|
|
return passResponses(game);
|
|
}
|
|
|
|
void test('Ixian Rihani completes its private exchange before native cyborg substitution and optional winner cleanup', () => {
|
|
for (const outcome of ['allow', 'cancel', 'decline'] as const) {
|
|
let game = stage('rihani-decipherer');
|
|
const shield = take(game, 'i', card => card.kind === 'shield');
|
|
const karama = take(game, 'e', card => card.effect === 'karama');
|
|
game = resolve(game, { defense: shield.id });
|
|
assert.equal(game.decision?.kind, 'rihani');
|
|
assert.deepEqual(game.pendingIxSubstitution?.losses, { 'wind_pass:14': 2 });
|
|
assert.equal(game.pendingIxSubstitution?.sources, undefined);
|
|
assert.equal(player(game, 'i').elites!.tanks, 2);
|
|
stable(game);
|
|
const event = game.rihaniHistory!.at(-1)!.event;
|
|
reject(game, 'i', { type: 'decision', sources: { 'wind_pass:14': 1 }, recover: { 'wind_pass:14': 1 } });
|
|
if (outcome === 'decline') game = act(game, 'i', { type: 'decision', event, draw: false });
|
|
else {
|
|
game = act(game, 'i', { type: 'decision', event, draw: true });
|
|
const receipt = game.rihaniHistory!.at(-1)!;
|
|
assert.equal(receipt.stage, 'return');
|
|
assert.deepEqual(viewGame(game, 'e').rihani!.history, []);
|
|
stable(game);
|
|
game = act(game, 'i', { type: 'decision', event, cards: [receipt.drawn[0], receipt.eligible[0]] });
|
|
}
|
|
const history = structuredClone(game.rihaniHistory);
|
|
assert.equal(game.lastBattleContext!.rihani!.completed, true);
|
|
stable(game);
|
|
game = finishSubstitution(game, outcome, karama.id);
|
|
assert.equal(game.pendingIxSubstitution, null);
|
|
assert.deepEqual(game.decision, { kind: 'battleCards', player: 'i', territory: 'wind_pass', cards: [shield.id] });
|
|
assert.equal(player(game, 'i').elites!.tanks, outcome === 'allow' ? 1 : 2);
|
|
assert.equal(player(game, 'i').elites!.forces['wind_pass:14'] ?? 0, outcome === 'allow' ? 1 : 0);
|
|
assert.deepEqual(game.rihaniHistory, history);
|
|
stable(game);
|
|
game = act(game, 'i', { type: 'decision', discard: [] });
|
|
assert.equal(game.rihaniHistory!.length, 1);
|
|
assert.equal(player(game, 'i').hand.some(card => card.id === shield.id), true);
|
|
stable(game);
|
|
}
|
|
});
|
|
|
|
void test('a delayed Ixian substitution preserves its original losses and cannot be removed or selected during Rihani', () => {
|
|
const game = resolve(stage('rihani-decipherer'));
|
|
assert.equal(game.decision?.kind, 'rihani');
|
|
const event = game.rihaniHistory!.at(-1)!.event;
|
|
const mutations: ((saved: Game) => void)[] = [
|
|
saved => { saved.pendingIxSubstitution!.losses = { 'wind_pass:15': 2 }; },
|
|
saved => { saved.pendingIxSubstitution!.sources = { 'wind_pass:14': 1 }; },
|
|
saved => { saved.pendingIxSubstitution!.recover = { 'wind_pass:14': 1 }; },
|
|
saved => { saved.pendingIxSubstitution = null; },
|
|
saved => { player(saved, 'i').elites!.tanks--; player(saved, 'i').elites!.reserves++; },
|
|
];
|
|
for (const mutate of mutations) {
|
|
const saved = reload(game);
|
|
mutate(saved);
|
|
reject(saved, 'i', { type: 'decision', event, draw: false });
|
|
}
|
|
const restored = act(game, 'i', { type: 'decision', event, draw: false });
|
|
assert.equal(restored.decision?.kind, 'ixSubstitution');
|
|
assert.deepEqual(restored.pendingIxSubstitution!.losses, { 'wind_pass:14': 2 });
|
|
stable(restored);
|
|
});
|
|
|
|
void test('an accepted Ixian substitution freezes matching source and recovery counts through its response', () => {
|
|
let game = stage('sandmaster');
|
|
take(game, 'e', card => card.effect === 'karama');
|
|
game = resolve(game);
|
|
game = act(game, 'i', { type: 'decision', sources: { 'wind_pass:14': 1 }, recover: { 'wind_pass:14': 1 } });
|
|
assert.equal(game.response?.kind, 'ixSubstitution');
|
|
for (const changeBoth of [false, true]) {
|
|
const saved = reload(game);
|
|
saved.pendingIxSubstitution!.recover = { 'wind_pass:14': 2 };
|
|
if (changeBoth) saved.pendingIxSubstitution!.sources = { 'wind_pass:14': 2 };
|
|
const voter = saved.players.find(p => !saved.response!.passed.includes(p.id));
|
|
assert.ok(voter);
|
|
reject(saved, voter.id, { type: 'passResponse' });
|
|
}
|
|
stable(game);
|
|
});
|
|
|
|
void test('every Ixian substitution outcome resumes mandatory Planetologist disposal before optional winner cards', () => {
|
|
const cases = [
|
|
...(['allow', 'cancel', 'decline'] as const).map(outcome => ({ outcome, card: 'treachery-24' })),
|
|
{ outcome: 'allow', card: 'ix-thumper' },
|
|
{ outcome: 'cancel', card: 'ix-harvester' },
|
|
{ outcome: 'decline', card: 'ix-amal' },
|
|
] as const;
|
|
for (const { outcome, card } of cases) {
|
|
let game = stage('planetologist');
|
|
const special = take(game, 'i', candidate => candidate.id === card);
|
|
const shield = take(game, 'i', card => card.kind === 'shield');
|
|
const karama = take(game, 'e', card => card.effect === 'karama');
|
|
game = resolve(game, { weapon: special.id, defense: shield.id });
|
|
assert.equal(game.decision?.kind, 'ixSubstitution');
|
|
assert.equal(game.pendingWinnerDiscards!.cards.includes(special.id), true);
|
|
assert.equal(player(game, 'i').hand.some(card => card.id === special.id), true);
|
|
assert.equal(game.discard.some(card => card.id === special.id), false);
|
|
stable(game);
|
|
game = finishSubstitution(game, outcome, karama.id);
|
|
assert.equal(game.pendingIxSubstitution, null);
|
|
assert.equal(game.pendingWinnerDiscards, null);
|
|
assert.equal(game.lastBattleContext!.winnerDiscards!.completed, true);
|
|
assert.deepEqual(game.decision, { kind: 'battleCards', player: 'i', territory: 'wind_pass', cards: [shield.id] });
|
|
assert.equal(game.discard.filter(card => card.id === special.id).length, 1);
|
|
reject(game, 'i', { type: 'decision', discard: [special.id] });
|
|
stable(game);
|
|
game = act(game, 'i', { type: 'decision', discard: [shield.id] });
|
|
assert.equal(game.discard.filter(card => card.id === special.id).length, 1);
|
|
assert.equal(game.discard.filter(card => card.id === shield.id).length, 1);
|
|
stable(game);
|
|
}
|
|
});
|
|
|
|
void test('a battle inside the HMS never uses spice in the territory beneath its pointer', () => {
|
|
for (const skill of ['smuggler', 'sandmaster'] as const) {
|
|
let game = stage(skill, MOBILE_LOCATION);
|
|
game.mobileStronghold!.location = 'wind_pass:14';
|
|
game.spice = { 'wind_pass:14': 8 };
|
|
game = resolve(game, { dial: 0 }, MOBILE_STRONGHOLD);
|
|
assert.equal(game.lastBattleContext!.territory, MOBILE_STRONGHOLD);
|
|
assert.deepEqual(game.spice, { 'wind_pass:14': 8 });
|
|
assert.equal(player(game, 'i').spice, 20);
|
|
assert.equal(game.pendingIxSubstitution, undefined);
|
|
stable(game);
|
|
}
|
|
});
|
|
|
|
for (const rescue of [true, false]) {
|
|
void test(rescue
|
|
? 'Ixian Suk rescues a suboid in place and cyborg to reserves before substituting only the remaining dead cyborg'
|
|
: 'declining skilled Ixian Suk rescue leaves both original cyborg casualties available for substitution', () => {
|
|
let game = stage('suk-graduate');
|
|
take(game, 'e', card => card.effect === 'karama');
|
|
game = resolve(game);
|
|
const decision = game.decision;
|
|
assert.equal(decision?.kind, 'sukRescue');
|
|
if (decision?.kind !== 'sukRescue') throw new Error('Expected the original Suk rescue');
|
|
assert.deepEqual(game.pendingSukRescue!.losses, { normal: 4, elite: 2, paidNormal: 0, paidElite: 0 });
|
|
assert.equal(game.pendingIxSubstitution, undefined);
|
|
assert.equal(player(game, 'i').tanks, 0);
|
|
stable(game);
|
|
const choice = decision.options.findIndex(option => rescue
|
|
? option.normal === 1 && option.elite === 1 && option.kept?.kind === 'normal' && option.kept.key === 'wind_pass:14'
|
|
: option.normal === 0 && option.elite === 0 && option.kept === null);
|
|
assert.ok(choice >= 0);
|
|
game = act(game, 'i', { type: 'decision', event: decision.event, choice });
|
|
const recover = rescue ? 1 : 2;
|
|
assert.equal(game.pendingSukRescue, null);
|
|
assert.equal(game.lastBattleContext!.sukRescue!.completed, true);
|
|
assert.equal(game.decision?.kind, 'ixSubstitution');
|
|
assert.deepEqual(game.pendingIxSubstitution!.losses, { 'wind_pass:14': recover });
|
|
assert.equal(player(game, 'i').elites!.reserves, rescue ? 6 : 5);
|
|
assert.equal(player(game, 'i').elites!.tanks, recover);
|
|
assert.equal(player(game, 'i').forces['wind_pass:14'], rescue ? 3 : 2);
|
|
assert.equal(player(game, 'i').tanks, rescue ? 4 : 6);
|
|
stable(game);
|
|
if (rescue) reject(game, 'i', { type: 'decision', sources: { 'wind_pass:14': 2 }, recover: { 'wind_pass:14': 2 } });
|
|
game = act(game, 'i', { type: 'decision', sources: { 'wind_pass:14': recover }, recover: { 'wind_pass:14': recover } });
|
|
assert.equal(game.response?.kind, 'ixSubstitution');
|
|
stable(game);
|
|
game = passResponses(game);
|
|
assert.equal(game.pendingIxSubstitution, null);
|
|
assert.equal(game.lastBattleContext!.ixSubstitution!.completed, true);
|
|
assert.equal(player(game, 'i').elites!.reserves, rescue ? 6 : 5);
|
|
assert.equal(player(game, 'i').elites!.tanks, 0);
|
|
assert.equal(player(game, 'i').elites!.forces['wind_pass:14'], recover);
|
|
assert.equal(player(game, 'i').forces['wind_pass:14'], rescue ? 3 : 2);
|
|
assert.equal(player(game, 'i').tanks, rescue ? 4 : 6);
|
|
stable(game);
|
|
});
|
|
}
|