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

278 lines
17 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 { ecazOccupyFixture, chooseEcazOccupyLead, cancelEcazOccupy, openEcazOccupyPlans,
stageEcazOccupyCard, ecazOccupyTerritoryForces, settleEcazOccupyArrival, type EcazOccupyFixture } from './fixture-ecaz-occupy';
import { advanceToNextStorm, finishToMovement } from './fixture-advanced-source';
const seat = (game: Game, id: string): Player => game.players.find(p => p.id === id)!;
const forces = (game: Game, fixture: EcazOccupyFixture, id: string) =>
ecazOccupyTerritoryForces(game, id, fixture.territory);
function availableLeader(game: Game, id: string, strongest: boolean): string {
const leaders = seat(game, id).leaders.filter(l => !l.dead && !l.usedAt);
return leaders.sort((a, b) => strongest ? b.strength - a.strength : a.strength - b.strength)[0].id;
}
/** A labelled physical Traitor Card exchange; no fabricated card or call. */
function holdTraitor(game: Game, owner: string, identity: string): void {
const receiver = seat(game, owner);
if (receiver.traitors.includes(identity)) return;
const reserve = game.traitorReserve ?? [];
const index = reserve.indexOf(identity);
if (index >= 0) {
reserve[index] = receiver.traitors[0];
receiver.traitors[0] = identity;
return;
}
const donor = game.players.find(p => p.traitors.includes(identity));
assert.ok(donor, 'The requested physical Traitor Card must exist outside the loyalty exclusion.');
const donorIndex = donor.traitors.indexOf(identity);
donor.traitors[donorIndex] = receiver.traitors[0];
receiver.traitors[0] = identity;
}
function traitorableLeader(game: Game, id: string): string {
const physical = [...(game.traitorReserve ?? []), ...game.players.flatMap(p => p.traitors)];
const leader = seat(game, id).leaders.find(l => !l.dead && !l.usedAt && physical.includes(l.id));
assert.ok(leader, 'A real non-loyal leader must have a physical Traitor Card.');
return leader.id;
}
function finishAftermath(state: Game): Game {
let game = state;
for (let attempt = 0; attempt < 100; attempt++) {
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.decision?.kind === 'battleCards') {
game = applyAction(game, game.decision.player, { type: 'decision', discard: [] });
} else if (game.pendingTreacheryDiscard) {
game = applyAction(game, game.players[0].id, { type: 'advanceBots' });
} else return game;
}
throw new Error('The native battle aftermath did not complete.');
}
function resolve(fixture: EcazOccupyFixture, lead: 'ecaz' | 'ally', options: {
outcome?: 'win' | 'loss' | 'soleTraitor' | 'mutualTraitors' | 'explosion';
typed?: boolean; canceled?: boolean;
} = {}): { game: Game; actor: string; before: Game } {
const outcome = options.outcome ?? 'win';
const actor = lead === 'ecaz' ? fixture.ecaz : fixture.ally;
let game = options.canceled ? cancelEcazOccupy(fixture, lead) : chooseEcazOccupyLead(fixture, lead);
game = openEcazOccupyPlans(game);
const before = structuredClone(game);
const profile = viewGame(game, actor).battle!.ecazOccupy!.profile!;
const fixed = profile.fixedEcazDial;
const free = seat(game, profile.forceOwner).faction === 'fremen';
const variable = outcome === 'loss' ? 0 : options.typed ? profile.maxDial - fixed : 2;
const support = free ? 0 : options.typed ? 4 : variable;
let ownLeader = availableLeader(game, actor, outcome !== 'loss');
const opponentLeader = availableLeader(game, fixture.opponent, outcome === 'loss');
const voter = actor === fixture.ecaz ? fixture.ally : fixture.ecaz;
if (outcome === 'soleTraitor' || outcome === 'mutualTraitors') {
holdTraitor(game, voter, opponentLeader);
if (outcome === 'mutualTraitors') {
ownLeader = traitorableLeader(game, actor);
holdTraitor(game, fixture.opponent, ownLeader);
}
}
const weapon = outcome === 'explosion' ? stageEcazOccupyCard(game, actor, c => c.kind === 'lasgun') : undefined;
const defense = outcome === 'explosion' ? stageEcazOccupyCard(game, fixture.opponent, c => c.kind === 'shield') : undefined;
game = applyAction(game, actor, { type: 'battlePlan', dial: fixed + variable,
support, leader: ownLeader, ...(weapon ? { weapon } : {}) });
game = applyAction(game, fixture.opponent, { type: 'battlePlan', dial: outcome === 'loss' ? 8 : 0,
support: outcome === 'loss' ? 8 : 0, leader: opponentLeader, ...(defense ? { defense } : {}) });
for (let attempt = 0; attempt < 10 && game.battle; attempt++) {
const view = viewGame(game, actor).battle!;
const next = view.traitorVoters.find(id => !view.traitorSubmitted.includes(id));
if (!next) break;
const call = next === voter && ['soleTraitor', 'mutualTraitors'].includes(outcome) ||
next === fixture.opponent && outcome === 'mutualTraitors';
game = applyAction(game, next, { type: 'traitorCall', call });
}
game = finishAftermath(game);
assert.equal(game.battle, null, 'The actual battle must resolve once, not remain a sealed-plan scaffold.');
assert.equal(game.decision, null, 'The actual native casualty and card aftermath must finish.');
return { game, actor, before };
}
// Keep the first source arithmetic case independently runnable before the batch.
void test('Advanced Occupy E1 Ecaz lead contributes one free strength and has no Ecaz survivor', () => {
const fixture = ecazOccupyFixture({ ecazForces: 1, allyFaction: 'guild' });
const { game, actor, before } = resolve(fixture, 'ecaz');
assert.equal(game.lastBattleContext!.winner, actor);
assert.equal(forces(game, fixture, fixture.ecaz), 0);
assert.equal(seat(game, fixture.ecaz).tanks - seat(before, fixture.ecaz).tanks, 1);
assert.equal(forces(game, fixture, fixture.ally), 2);
assert.equal(seat(before, actor).spice - seat(game, actor).spice, 2,
'Only the actual ally commitment costs the selected lead spice.');
assert.equal(forces(game, fixture, fixture.opponent), 0);
});
for (const ecazForces of [1, 2, 3, 4, 5]) for (const lead of ['ecaz', 'ally'] as const)
for (const order of ['ecaz-first', 'ally-first', 'opponent-first'] as const) {
if (ecazForces === 1 && lead === 'ecaz' && order === 'ecaz-first') continue;
void test(`Advanced Occupy E${ecazForces}, ${lead} lead, ${order}: floor survivors and original payer`, () => {
const fixture = ecazOccupyFixture({ ecazForces, allyFaction: 'guild', order });
const expectedOrder = order === 'ecaz-first' ? [fixture.ecaz, fixture.ally, fixture.opponent] :
order === 'ally-first' ? [fixture.ally, fixture.opponent, fixture.ecaz] : [fixture.opponent, fixture.ecaz, fixture.ally];
assert.deepEqual(fixture.game.order, expectedOrder, 'Printed circles must create the requested real order.');
const { game, actor, before } = resolve(fixture, lead);
const nonLead = actor === fixture.ecaz ? fixture.ally : fixture.ecaz;
assert.equal(game.lastBattleContext!.winner, actor);
assert.equal(forces(game, fixture, fixture.ecaz), Math.floor(ecazForces / 2));
assert.equal(seat(game, fixture.ecaz).tanks - seat(before, fixture.ecaz).tanks, Math.ceil(ecazForces / 2));
assert.equal(forces(game, fixture, fixture.ally), 2);
assert.equal(seat(before, actor).spice - seat(game, actor).spice, 2);
assert.equal(seat(before, nonLead).spice, seat(game, nonLead).spice);
assert.deepEqual(seat(game, nonLead).hand, seat(before, nonLead).hand);
assert.ok(game.phase >= 7, 'The one collapsed coalition battle continues to native collection.');
});
}
for (const lead of ['ecaz', 'ally'] as const)
void test(`Selected ${lead} lead retains its actual ordinary tie position, not the coalition chooser's position`, () => {
const fixture = ecazOccupyFixture({ ecazForces: 1, allyFaction: 'guild', order: 'ally-first' });
const actor = lead === 'ecaz' ? fixture.ecaz : fixture.ally;
let game = openEcazOccupyPlans(chooseEcazOccupyLead(fixture, lead));
const own = seat(game, actor).leaders.find(l => !l.dead &&
seat(game, fixture.opponent).leaders.some(other => !other.dead && other.strength === l.strength));
assert.ok(own, 'The native rosters must supply two equal-strength available leaders.');
const other = seat(game, fixture.opponent).leaders.find(l => !l.dead && l.strength === own.strength)!;
game = applyAction(game, actor, { type: 'battlePlan', dial: 1, support: 0, leader: own.id });
game = applyAction(game, fixture.opponent, { type: 'battlePlan', dial: 1, support: 1, leader: other.id });
for (const voter of viewGame(game, actor).battle!.traitorVoters)
game = applyAction(game, voter, { type: 'traitorCall', call: false });
game = finishAftermath(game);
assert.equal(game.lastBattleContext!.winner, lead === 'ecaz' ? fixture.opponent : fixture.ally);
});
for (const allyFaction of ['fremen', 'emperor'] as const) for (const lead of ['ecaz', 'ally'] as const)
void test(`Advanced Occupy ${allyFaction} typed elite commitment retains its force owner with ${lead} lead`, () => {
const fixture = ecazOccupyFixture({ allyFaction, ecazForces: 5 });
const { game, actor, before } = resolve(fixture, lead, { typed: true });
assert.equal(game.lastBattleContext!.winner, actor);
assert.equal(forces(game, fixture, fixture.ecaz), 2);
assert.equal(forces(game, fixture, fixture.ally), 0);
assert.equal(seat(game, fixture.ally).elites!.tanks - seat(before, fixture.ally).elites!.tanks, 2);
assert.equal(seat(before, actor).spice - seat(game, actor).spice, allyFaction === 'fremen' ? 0 : 4);
});
for (const lead of ['ecaz', 'ally'] as const)
void test(`Native allied Sardaukar lose their double strength against Fremen with ${lead} lead`, () => {
const fixture = ecazOccupyFixture({ allyFaction: 'emperor', opponentFaction: 'fremen', ecazForces: 5 });
const prepared = openEcazOccupyPlans(chooseEcazOccupyLead(fixture, lead));
const actor = lead === 'ecaz' ? fixture.ecaz : fixture.ally;
const profile = viewGame(prepared, actor).battle!.ecazOccupy!.profile!;
assert.equal(profile.maxDial - profile.fixedEcazDial, 4,
'The actual enemy faction, not the Ecaz plan actor, determines Sardaukar strength.');
const { game } = resolve(fixture, lead, { typed: true });
assert.equal(forces(game, fixture, fixture.ally), 0);
assert.equal(seat(game, fixture.ally).elites!.tanks, 2);
});
for (const outcome of ['loss', 'soleTraitor', 'mutualTraitors', 'explosion'] as const)
for (const lead of ['ecaz', 'ally'] as const)
void test(`Advanced Occupy true ${outcome} with ${lead} lead settles both physical coalition pools`, () => {
const fixture = ecazOccupyFixture({ allyFaction: 'guild', ecazForces: 5 });
const { game, actor, before } = resolve(fixture, lead, { outcome });
if (outcome === 'soleTraitor') {
assert.equal(game.lastBattleContext!.result, 'traitor');
assert.equal(game.lastBattleContext!.winner, actor);
assert.equal(forces(game, fixture, fixture.ecaz), 5);
assert.equal(forces(game, fixture, fixture.ally), 4);
assert.equal(seat(game, fixture.ecaz).tanks, seat(before, fixture.ecaz).tanks);
assert.equal(seat(game, fixture.ally).tanks, seat(before, fixture.ally).tanks);
} else {
assert.equal(game.lastBattleContext!.result, outcome === 'loss' ? 'normal' : outcome);
assert.equal(game.lastBattleContext!.winner, outcome === 'loss' ? fixture.opponent : null);
assert.equal(forces(game, fixture, fixture.ecaz), 0);
assert.equal(forces(game, fixture, fixture.ally), 0);
assert.equal(seat(game, fixture.ecaz).tanks - seat(before, fixture.ecaz).tanks, 5);
assert.equal(seat(game, fixture.ally).tanks - seat(before, fixture.ally).tanks, 4);
}
});
for (const lead of ['ecaz', 'ally'] as const)
void test(`Printed Karama with ${lead} lead recomputes own physical dial before seal`, () => {
const fixture = ecazOccupyFixture({ allyFaction: 'guild', ecazForces: 5 });
const { game, actor } = resolve(fixture, lead, { canceled: true });
assert.equal(game.lastBattleContext!.winner, actor);
assert.equal(forces(game, fixture, fixture.ecaz), lead === 'ecaz' ? 3 : 5);
assert.equal(forces(game, fixture, fixture.ally), lead === 'ally' ? 2 : 4);
});
void test('Native BG Worthless conversion cancels Occupy without changing selected lead or alliance', () => {
const fixture = ecazOccupyFixture({ allyFaction: 'guild', opponentFaction: 'beneGesserit' });
const game = openEcazOccupyPlans(cancelEcazOccupy(fixture, 'ecaz', true));
const profile = viewGame(game, fixture.ecaz).battle!.ecazOccupy!.profile!;
assert.equal(profile.canceled, true);
assert.equal(profile.forceOwner, fixture.ecaz);
assert.equal(profile.fixedEcazDial, 0);
assert.equal(seat(game, fixture.ecaz).ally, fixture.ally);
assert.equal(seat(game, fixture.ally).ally, fixture.ecaz);
});
void test('Native allied BG advisors are excluded from the Occupy fighter side and lead choice', () => {
const fixture = ecazOccupyFixture({ allyFaction: 'beneGesserit', advisorAlly: true });
assert.ok(seat(fixture.game, fixture.ally).advisors?.[fixture.territory]);
assert.notEqual(fixture.game.decision?.kind, 'ecazBattleLead');
assert.equal(viewGame(fixture.game, fixture.ecaz).battle!.ecazOccupy, null);
});
void test('Native aftermath retires only selected lead cards and four minimal policies can continue', () => {
const fixture = ecazOccupyFixture({ allyFaction: 'guild' });
const shield = stageEcazOccupyCard(fixture.game, fixture.ecaz, card => card.kind === 'shield');
let game = openEcazOccupyPlans(chooseEcazOccupyLead(fixture, 'ecaz'));
const nonLeadHand = structuredClone(seat(game, fixture.ally).hand);
game = applyAction(game, fixture.ecaz, { type: 'battlePlan', dial: 4, support: 2,
leader: availableLeader(game, fixture.ecaz, true), defense: shield });
game = applyAction(game, fixture.opponent, { type: 'battlePlan', dial: 0, support: 0,
leader: availableLeader(game, fixture.opponent, false) });
for (const voter of viewGame(game, fixture.ecaz).battle!.traitorVoters)
game = applyAction(game, voter, { type: 'traitorCall', call: false });
assert.equal(game.decision?.kind, 'battleCards');
game = applyAction(game, game.decision!.player, { type: 'decision', discard: [shield] });
game = finishAftermath(game);
assert.ok(game.discard.some(card => card.id === shield));
assert.deepEqual(seat(game, fixture.ally).hand, nonLeadHand);
for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
const view = viewGame(game, fixture.ecaz);
view.players.find(p => p.id === fixture.ecaz)!.bot = difficulty;
const candidates = botActions(view);
const action = candidates.find(a => a.type === 'ready');
assert.ok(action, `${difficulty} continues the actual post-battle phase.`);
const next = applyAction(game, fixture.ecaz, action);
assert.ok(next.ready.includes(fixture.ecaz) || next.phase > game.phase);
}
});
void test('Surviving coalition fights a second native battle next turn without a phase or plan retrofit', () => {
const fixture = ecazOccupyFixture({ ecazForces: 5, allyFaction: 'guild' });
let game = resolve(fixture, 'ecaz').game;
const firstEvent = game.lastBattleContext!.event;
game = advanceToNextStorm(game);
// Reorder only unplayed physical territory faces to avoid an unrelated new Nexus.
for (const position of [0, 1]) {
const index = game.spiceDeck.findIndex((card, i) => i >= position &&
'territory' in card && card.territory !== fixture.territory);
assert.ok(index >= position);
game.spiceDeck.splice(position, 0, game.spiceDeck.splice(index, 1)[0]);
}
game = finishToMovement(game);
while (game.phase === 5) {
const actor = game.active!;
if (actor === fixture.opponent) {
game = applyAction(game, actor, { type: 'ship', territory: fixture.territory, sector: 15, amount: 2 });
game = settleEcazOccupyArrival(game);
}
game = applyAction(game, actor, { type: 'endMovement' });
}
while (game.phaseOpening)
game = applyAction(game, game.players.find(p => !game.phaseOpening!.passed.includes(p.id))!.id,
{ type: 'ready' });
const choice = viewGame(game, game.active!).battleChoices.find(b => b.territory === fixture.territory)!;
assert.ok(choice);
game = applyAction(game, choice.chooser, { type: 'chooseBattle', territory: fixture.territory,
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker });
assert.equal(game.decision?.kind, 'ecazBattleLead');
const result = resolve({ ...fixture, game, ecazForces: 2 }, 'ecaz').game;
assert.notEqual(result.lastBattleContext!.event, firstEvent);
assert.equal(result.lastBattleContext!.turn, fixture.game.turn + 1);
assert.equal(forces(result, fixture, fixture.ecaz), 1);
assert.equal(forces(result, fixture, fixture.ally), 0);
assert.ok(result.phase >= 7);
});