Files
Dune/tests/ecaz-occupy-tleilaxu-runtime.test.ts

155 lines
8.9 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, viewGame, type Game, type Player } from '../game/engine';
import { botActions } from '../game/bots';
import { splitLocation } from '../game/board';
import { ecazOccupyFixture, createEcazOccupySetup, chooseEcazOccupyLead,
openEcazOccupyPlans, stageEcazOccupyCard, ecazOccupyTerritoryForces,
type EcazOccupyFixture } from './fixture-ecaz-occupy';
const seat = (game: Game, id: string): Player => {
const player = game.players.find(p => p.id === id); assert.ok(player); return player;
};
const army = (game: Game, fixture: EcazOccupyFixture, id: string) =>
ecazOccupyTerritoryForces(game, id, fixture.territory);
function aftermath(state: Game): Game {
let game = state;
for (let n = 0; n < 100; n++) {
if (game.response) game = applyAction(game,
game.players.find(p => !game.response!.passed.includes(p.id))!.id, { type: 'passResponse' });
else if (game.decision?.kind === 'battleLosses')
game = applyAction(game, game.decision.player, { type: 'decision', choice: 0 });
else if (game.pendingTreacheryDiscard)
game = applyAction(game, game.players[0].id, { type: 'advanceBots' });
else return game;
}
throw Error('Native Occupy aftermath did not finish.');
}
function originalWin(fixture: EcazOccupyFixture, lead: 'ecaz' | 'ally') {
const actor = lead === 'ecaz' ? fixture.ecaz : fixture.ally;
const tleilaxu = fixture.game.players.find(p => p.faction === 'tleilaxu')!.id;
const held = seat(fixture.game, tleilaxu).faceDancers!;
const candidates = seat(fixture.game, actor).leaders.filter(l => !l.dead && !l.usedAt);
const winner = candidates.find(l => held.some(c => !c.revealed && c.leader === l.id));
assert.ok(winner, 'Use an actual first-draw Face Dancer and the selected lead\'s real leader.');
const loser = seat(fixture.game, fixture.opponent).leaders.filter(l => !l.dead && !l.usedAt)
.sort((a, b) => a.strength - b.strength)[0];
const weapon = stageEcazOccupyCard(fixture.game, actor, card => card.kind === 'projectile');
const shield = stageEcazOccupyCard(fixture.game, actor, card => card.kind === 'shield');
let game = openEcazOccupyPlans(chooseEcazOccupyLead(fixture, lead));
const before = structuredClone(game);
const fixed = viewGame(game, actor).battle!.ecazOccupy!.profile!.fixedEcazDial;
game = applyAction(game, actor, { type: 'battlePlan', dial: fixed + 2,
support: 2, leader: winner.id, weapon, defense: shield });
game = applyAction(game, fixture.opponent, { type: 'battlePlan', dial: 0,
support: 0, leader: loser.id });
for (let n = 0; n < 10 && game.battle; n++) {
const view = viewGame(game, actor).battle!;
const voter = view.traitorVoters.find(id => !view.traitorSubmitted.includes(id));
if (!voter) break;
game = applyAction(game, voter, { type: 'traitorCall', call: false });
}
game = aftermath(game);
assert.equal(game.decision?.kind, 'battleCards');
return { game, before, actor, tleilaxu, winner: winner.id, loser: loser.id,
bounty: loser.strength, weapon, shield };
}
function continueNativeBattlePhase(state: Game): Game {
let game = state;
for (let n = 0; n < 30 && game.phase === 6; n++) {
const actor = game.players.find(p => !game.ready.includes(p.id)); assert.ok(actor);
const view = viewGame(game, actor.id);
view.players.find(p => p.id === actor.id)!.bot = 'Easy';
const action = botActions(view).find(candidate => candidate.type === 'ready'); assert.ok(action);
game = applyAction(game, actor.id, action);
}
assert.equal(game.phase, 7, 'The original battle must continue naturally to Spice Collection.');
return game;
}
for (const lead of ['ecaz', 'ally'] as const) {
void test(`Tleilaxu winning Occupy coside cannot Face Dance its selected ${lead} leader`, () => {
const fixture = ecazOccupyFixture({ allyFaction: 'tleilaxu',
faceDancerFaction: lead === 'ecaz' ? 'ecaz' : 'tleilaxu' });
const win = originalWin(fixture, lead);
const voters = viewGame(win.before, win.actor).battle!.traitorVoters;
assert.ok(voters.includes(fixture.ecaz) && voters.includes(fixture.ally),
'Both actual same-faction participants receive their native traitor declaration.');
let game = applyAction(win.game, win.actor, { type: 'decision', discard: [win.shield] });
game = aftermath(game);
assert.equal(game.battle, null);
assert.equal(game.decision, null, 'No winning-coside Face Dance opportunity may be offered.');
assert.equal(army(game, fixture, fixture.ecaz), 1);
assert.equal(army(game, fixture, fixture.ally), 2);
assert.equal(seat(game, win.actor).leaders.find(l => l.id === win.winner)!.dead, false);
assert.ok(seat(game, win.tleilaxu).faceDancers!.some(c => c.leader === win.winner && !c.revealed));
assert.equal(seat(game, win.actor).spice, seat(win.before, win.actor).spice - 2 + win.bounty);
assert.ok(seat(game, win.actor).hand.some(c => c.id === win.weapon));
assert.ok(game.discard.some(c => c.id === win.shield));
continueNativeBattlePhase(game);
});
}
for (const lead of ['ecaz', 'ally'] as const)
for (const origin of ['reserves', 'board'] as const)
for (const count of [0, 1, 3]) {
void test(`External Tleilaxu replaces both ${lead}-led Occupy armies: ${origin} ${count}/3`, () => {
const options = { allyFaction: 'guild', opponentFaction: 'emperor',
roster: ['ecaz', 'guild', 'emperor', 'tleilaxu'], expansions: ['ecaz', 'ix'],
faceDancerFaction: lead === 'ecaz' ? 'ecaz' : 'guild' } as const;
const initial = createEcazOccupySetup({ ...options, roster: [...options.roster], expansions: [...options.expansions] });
const fixture = ecazOccupyFixture({ ...options, initial,
roster: [...options.roster], expansions: [...options.expansions] });
const tleilaxu = fixture.game.players.find(p => p.faction === 'tleilaxu')!.id;
// Labelled conserved physical source placement outside the battle:
// five original Tleilaxu reserve counters become Arrakeen board sources.
const source = 'arrakeen:10';
seat(fixture.game, tleilaxu).reserves -= 5;
seat(fixture.game, tleilaxu).forces[source] = 5;
const win = originalWin(fixture, lead);
let game = applyAction(win.game, win.actor, { type: 'decision', discard: [win.shield] });
game = aftermath(game);
const decision = game.decision;
assert.ok(decision?.kind === 'faceDance');
assert.equal(decision.winner, win.actor);
assert.equal(decision.leader, win.winner);
assert.equal(army(game, fixture, fixture.ecaz), 1);
assert.equal(army(game, fixture, fixture.ally), 2);
assert.equal(seat(game, win.actor).spice, seat(win.before, win.actor).spice - 2 + win.bounty);
const beforeDance = structuredClone(game);
const nonLead = win.actor === fixture.ecaz ? fixture.ally : fixture.ecaz;
const nonLeadHand = structuredClone(seat(game, nonLead).hand);
const sector = splitLocation(fixture.location).sector;
assert.throws(() => applyAction(game, tleilaxu,
{ type: 'decision', reveal: true, sources: { reserves: 4 }, sector }), /more forces/,
'Replacement cannot exceed the whole surviving coalition.');
const sources = origin === 'reserves' ? { reserves: count } : { [source]: count };
game = applyAction(game, tleilaxu, { type: 'decision', reveal: true, sources, sector });
game = aftermath(game);
assert.equal(army(game, fixture, fixture.ecaz), 0);
assert.equal(army(game, fixture, fixture.ally), 0);
assert.equal(army(game, fixture, tleilaxu), count);
assert.equal(seat(game, fixture.ecaz).reserves, seat(beforeDance, fixture.ecaz).reserves + 1);
assert.equal(seat(game, fixture.ally).reserves, seat(beforeDance, fixture.ally).reserves + 2);
assert.equal(seat(game, tleilaxu).reserves,
seat(beforeDance, tleilaxu).reserves - (origin === 'reserves' ? count : 0));
assert.equal(seat(game, tleilaxu).forces[source], 5 - (origin === 'board' ? count : 0));
assert.equal(seat(game, win.actor).leaders.find(l => l.id === win.winner)!.dead, true);
assert.equal(seat(game, fixture.opponent).leaders.find(l => l.id === win.loser)!.dead, true);
assert.equal(seat(game, tleilaxu).spice, seat(beforeDance, tleilaxu).spice,
'Face Dance leader death is not a second bounty.');
assert.equal(seat(game, win.actor).spice, seat(beforeDance, win.actor).spice,
'Original selected winner retains its bounty after replacement.');
assert.ok(seat(game, win.actor).hand.some(c => c.id === win.weapon));
assert.ok(game.discard.some(c => c.id === win.shield));
assert.deepEqual(seat(game, nonLead).hand, nonLeadHand);
assert.ok(seat(game, tleilaxu).faceDancers!.some(c => c.leader === win.winner && c.revealed));
assert.equal(game.battle, null);
assert.equal(game.decision, null);
continueNativeBattlePhase(game);
});
}