278 lines
17 KiB
TypeScript
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);
|
|
});
|