328 lines
19 KiB
TypeScript
328 lines
19 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 { TERRITORIES, location as locationKey } from '../game/board';
|
|
import { ecazOccupyDialChoices, ecazOccupyPlanControl } from '../game/ecaz-occupy-options';
|
|
import { advanceToNextStorm, finishToMovement } from './fixture-advanced-source';
|
|
import { chooseEcazOccupyLead, cancelEcazOccupy, openEcazOccupyPlans,
|
|
ecazOccupyTerritoryForces, stageEcazOccupyCard, allowEcazOccupyResponses,
|
|
type EcazOccupyFixture } from './fixture-ecaz-occupy';
|
|
import { basicEcazOccupyFixture } from './fixture-basic-ecaz-occupy';
|
|
|
|
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);
|
|
const reload = (game: Game): Game => JSON.parse(JSON.stringify(game));
|
|
function leader(game: Game, id: string, strongest: boolean): string {
|
|
return seat(game, id).leaders.filter(l => !l.dead && !l.usedAt)
|
|
.sort((a, b) => strongest ? b.strength - a.strength : a.strength - b.strength)[0].id;
|
|
}
|
|
function reject(game: Game, actor: string, action: Parameters<typeof applyAction>[2]): void {
|
|
const before = structuredClone(game);
|
|
assert.throws(() => applyAction(game, actor, action));
|
|
assert.deepEqual(game, before, 'A rejected native action cannot commit any part of the battle.');
|
|
}
|
|
|
|
/** Exchange an existing physical Traitor Card, preserving the native deck. */
|
|
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;
|
|
} else {
|
|
const donor = game.players.find(p => p.traitors.includes(identity));
|
|
assert.ok(donor, 'The chosen leader must have a real physical Traitor Card.');
|
|
const donorIndex = donor.traitors.indexOf(identity);
|
|
donor.traitors[donorIndex] = receiver.traitors[0];
|
|
receiver.traitors[0] = identity;
|
|
}
|
|
}
|
|
|
|
function aftermath(state: Game): Game {
|
|
let game = state;
|
|
for (let attempt = 0; attempt < 100; attempt++) {
|
|
if (game.response) game = allowEcazOccupyResponses(game);
|
|
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 original Basic battle aftermath did not finish.');
|
|
}
|
|
|
|
function resolve(fixture: EcazOccupyFixture, lead: 'ecaz' | 'ally', options: {
|
|
outcome?: 'win' | 'loss' | 'traitor' | 'mutualTraitors' | 'explosion'; canceled?: boolean;
|
|
} = {}): { game: Game; before: Game; actor: string } {
|
|
const actor = lead === 'ecaz' ? fixture.ecaz : fixture.ally;
|
|
const outcome = options.outcome ?? 'win';
|
|
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 voter = actor === fixture.ecaz ? fixture.ally : fixture.ecaz;
|
|
let ownLeader = leader(game, actor, outcome !== 'loss');
|
|
const opponentLeader = leader(game, fixture.opponent, outcome === 'loss');
|
|
if (outcome === 'traitor' || outcome === 'mutualTraitors') {
|
|
holdTraitor(game, voter, opponentLeader);
|
|
if (outcome === 'mutualTraitors') {
|
|
const physical = [...(game.traitorReserve ?? []), ...game.players.flatMap(p => p.traitors)];
|
|
ownLeader = seat(game, actor).leaders.find(l => !l.dead && !l.usedAt && physical.includes(l.id))!.id;
|
|
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: profile.fixedEcazDial + (outcome === 'loss' ? 0 : 2),
|
|
support: 0, leader: ownLeader, ...(weapon ? { weapon } : {}) });
|
|
game = applyAction(game, fixture.opponent, { type: 'battlePlan', dial: outcome === 'loss' ? 8 : 0,
|
|
support: 0, leader: opponentLeader, ...(defense ? { defense } : {}) });
|
|
for (let attempt = 0; attempt < 10 && game.battle; attempt++) {
|
|
const battle = viewGame(game, actor).battle!;
|
|
const next = battle.traitorVoters.find(id => !battle.traitorSubmitted.includes(id));
|
|
if (!next) break;
|
|
const call = next === voter && ['traitor', 'mutualTraitors'].includes(outcome) ||
|
|
next === fixture.opponent && outcome === 'mutualTraitors';
|
|
game = applyAction(game, next, { type: 'traitorCall', call });
|
|
}
|
|
game = aftermath(game);
|
|
assert.equal(game.battle, null, 'One original combined battle must retire, not duplicate a second fight.');
|
|
return { game, before, actor };
|
|
}
|
|
|
|
for (const ecazForces of [2, 4]) for (const lead of ['ecaz', 'ally'] as const)
|
|
void test(`Basic E${ecazForces} ${lead} lead: free full-strength commitment and equal Ecaz loss/survival`, () => {
|
|
const fixture = basicEcazOccupyFixture({ ecazForces });
|
|
const actor = lead === 'ecaz' ? fixture.ecaz : fixture.ally;
|
|
const pending = reload(fixture.game); // Only an actual pending lead decision is resumed.
|
|
const prepared = openEcazOccupyPlans(chooseEcazOccupyLead({ ...fixture, game: pending }, lead));
|
|
const view = viewGame(prepared, actor);
|
|
const profile = view.battle!.ecazOccupy!.profile!;
|
|
assert.equal(profile.fixedEcazDial, ecazForces / 2);
|
|
assert.equal(profile.forceOwner, fixture.ally);
|
|
assert.equal(profile.maxDial, ecazForces / 2 + 4);
|
|
assert.equal(profile.maxSupport, 0);
|
|
assert.equal(ecazOccupyPlanControl(view, ecazForces / 2 + 2, 0)!.blocked, null);
|
|
assert.notEqual(ecazOccupyPlanControl(view, ecazForces / 2 + 2, 1)!.blocked, null);
|
|
const { game, before } = resolve(fixture, lead);
|
|
assert.equal(game.lastBattleContext!.winner, actor);
|
|
assert.equal(game.lastBattleContext!.result, 'normal');
|
|
assert.equal(forces(game, fixture, fixture.ecaz), ecazForces / 2);
|
|
assert.equal(seat(game, fixture.ecaz).tanks - seat(before, fixture.ecaz).tanks, ecazForces / 2);
|
|
assert.equal(forces(game, fixture, fixture.ally), 2);
|
|
assert.equal(seat(game, fixture.ally).tanks - seat(before, fixture.ally).tanks, 2);
|
|
assert.equal(forces(game, fixture, fixture.opponent), 0);
|
|
for (const id of [fixture.ecaz, fixture.ally]) assert.equal(seat(game, id).spice, seat(before, id).spice);
|
|
assert.equal(game.phase, 7, 'Surviving allies coexist into original Collection without resettlement.');
|
|
});
|
|
|
|
for (const lead of ['ecaz', 'ally'] as const)
|
|
void test(`Basic ${lead} lead resolves a mandatory Hero discard before its variable-army casualties`, () => {
|
|
const fixture = basicEcazOccupyFixture({ ecazForces: 4 });
|
|
const actor = lead === 'ecaz' ? fixture.ecaz : fixture.ally;
|
|
let game = openEcazOccupyPlans(chooseEcazOccupyLead(fixture, lead));
|
|
const hero = stageEcazOccupyCard(game, fixture.opponent, card => card.kind === 'hero');
|
|
const before = structuredClone(game);
|
|
game = applyAction(game, actor, {
|
|
type: 'battlePlan', dial: 4, leader: leader(game, actor, true),
|
|
});
|
|
game = applyAction(game, fixture.opponent, { type: 'battlePlan', dial: 0, leader: hero });
|
|
while (game.battle) {
|
|
const view = viewGame(game, actor).battle!;
|
|
const voter = view.traitorVoters.find(id => !view.traitorSubmitted.includes(id));
|
|
assert.ok(voter, 'The real revealed battle must retain its Traitor voters.');
|
|
game = applyAction(game, voter, { type: 'traitorCall', call: false });
|
|
}
|
|
game = aftermath(game);
|
|
assert.equal(game.lastBattleContext!.winner, actor);
|
|
assert.equal(game.lastBattleContext!.result, 'normal');
|
|
assert.equal(game.discard.filter(card => card.id === hero).length, 1);
|
|
assert.equal(seat(game, fixture.opponent).hand.some(card => card.id === hero), false);
|
|
for (const id of [fixture.ecaz, fixture.ally]) {
|
|
assert.equal(forces(game, fixture, id), 2);
|
|
assert.equal(seat(game, id).tanks - seat(before, id).tanks, 2);
|
|
}
|
|
assert.equal(forces(game, fixture, fixture.opponent), 0);
|
|
assert.equal(game.phase, 7);
|
|
});
|
|
|
|
for (const allyFaction of ['emperor', 'fremen'] as const) for (const lead of ['ecaz', 'ally'] as const)
|
|
void test(`Basic ${allyFaction} ${lead} lead uses native ordinary inventory without Advanced elite strength`, () => {
|
|
const fixture = basicEcazOccupyFixture({ allyFaction, ecazForces: 4 });
|
|
const actor = lead === 'ecaz' ? fixture.ecaz : fixture.ally;
|
|
const prepared = openEcazOccupyPlans(chooseEcazOccupyLead(fixture, lead));
|
|
const profile = viewGame(prepared, actor).battle!.ecazOccupy!.profile!;
|
|
assert.equal(profile.maxDial, 6, 'Four real Basic ordinary forces provide four variable strength.');
|
|
const { game, before } = resolve(fixture, lead);
|
|
assert.equal(game.lastBattleContext!.winner, actor);
|
|
assert.equal(forces(game, fixture, fixture.ally), 2);
|
|
assert.equal(seat(game, actor).spice, seat(before, actor).spice);
|
|
});
|
|
|
|
for (const outcome of ['loss', 'traitor', 'mutualTraitors', 'explosion'] as const)
|
|
for (const lead of ['ecaz', 'ally'] as const)
|
|
void test(`Basic ${outcome} with ${lead} lead settles both physical armies through original handlers`, () => {
|
|
const fixture = basicEcazOccupyFixture({ ecazForces: 4 });
|
|
const { game, before, actor } = resolve(fixture, lead, { outcome });
|
|
assert.equal(game.lastBattleContext!.result, outcome === 'loss' ? 'normal' : outcome);
|
|
assert.equal(game.lastBattleContext!.winner, outcome === 'traitor' ? actor : outcome === 'loss' ? fixture.opponent : null);
|
|
for (const [id, count] of [[fixture.ecaz, 4], [fixture.ally, 4]] as const) {
|
|
assert.equal(forces(game, fixture, id), outcome === 'traitor' ? count : 0);
|
|
assert.equal(seat(game, id).tanks - seat(before, id).tanks, outcome === 'traitor' ? 0 : count);
|
|
const bounty = outcome === 'traitor' && id === actor
|
|
? seat(before, fixture.opponent).leaders.find(l => l.id === leader(before, fixture.opponent, false))!.strength : 0;
|
|
assert.equal(seat(game, id).spice - seat(before, id).spice, bounty,
|
|
'Basic support is free while the original defeated-leader bounty still belongs to the lead.');
|
|
}
|
|
});
|
|
|
|
for (const lead of ['ecaz', 'ally'] as const)
|
|
void test(`Basic printed Karama after ${lead} lead permits only that lead's own free ordinary dial`, () => {
|
|
const fixture = basicEcazOccupyFixture({ ecazForces: 4 });
|
|
const actor = lead === 'ecaz' ? fixture.ecaz : fixture.ally;
|
|
const canceled = cancelEcazOccupy(fixture, lead);
|
|
const prepared = openEcazOccupyPlans(canceled);
|
|
const profile = viewGame(prepared, actor).battle!.ecazOccupy!.profile!;
|
|
assert.equal(profile.canceled, true);
|
|
assert.equal(profile.fixedEcazDial, 0);
|
|
assert.equal(profile.forceOwner, actor);
|
|
assert.equal(profile.maxDial, 4);
|
|
const { game, before } = resolve(fixture, lead, { canceled: true });
|
|
assert.equal(game.lastBattleContext!.winner, actor);
|
|
assert.equal(forces(game, fixture, fixture.ecaz), lead === 'ecaz' ? 2 : 4);
|
|
assert.equal(forces(game, fixture, fixture.ally), lead === 'ally' ? 2 : 4);
|
|
assert.equal(seat(game, actor).spice, seat(before, actor).spice);
|
|
assert.equal(seat(game, fixture.ecaz).ally, fixture.ally);
|
|
});
|
|
|
|
for (const lead of ['ecaz', 'ally'] as const)
|
|
void test(`Basic ${lead} lead owns printed plan cards and its original aftermath discard`, () => {
|
|
const fixture = basicEcazOccupyFixture();
|
|
const actor = lead === 'ecaz' ? fixture.ecaz : fixture.ally;
|
|
const nonLead = actor === fixture.ecaz ? fixture.ally : fixture.ecaz;
|
|
const shield = stageEcazOccupyCard(fixture.game, actor, card => card.kind === 'shield');
|
|
let game = openEcazOccupyPlans(chooseEcazOccupyLead(fixture, lead));
|
|
const nonLeadHand = structuredClone(seat(game, nonLead).hand);
|
|
reject(game, nonLead, { type: 'battlePlan', dial: 4, support: 0,
|
|
leader: leader(game, nonLead, true), defense: shield });
|
|
game = applyAction(game, actor, { type: 'battlePlan', dial: 4, support: 0,
|
|
leader: leader(game, actor, true), defense: shield });
|
|
game = applyAction(game, fixture.opponent, { type: 'battlePlan', dial: 0, support: 0,
|
|
leader: leader(game, fixture.opponent, false) });
|
|
for (const voter of viewGame(game, actor).battle!.traitorVoters)
|
|
game = applyAction(game, voter, { type: 'traitorCall', call: false });
|
|
assert.equal(game.decision?.kind, 'battleCards');
|
|
assert.equal(game.decision!.player, actor);
|
|
const pending = reload(game);
|
|
game = applyAction(pending, actor, { type: 'decision', discard: [shield] });
|
|
game = aftermath(game);
|
|
assert.ok(game.discard.some(card => card.id === shield));
|
|
assert.ok(!seat(game, actor).hand.some(card => card.id === shield));
|
|
assert.deepEqual(seat(game, nonLead).hand, nonLeadHand);
|
|
assert.equal(game.phase, 7);
|
|
});
|
|
|
|
|
|
for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const)
|
|
for (const lead of ['ecaz', 'ally'] as const)
|
|
void test(`Basic ${difficulty} policy selects ${lead} and seals a legal actual-owner plan`, () => {
|
|
const fixture = basicEcazOccupyFixture();
|
|
const actor = lead === 'ecaz' ? fixture.ecaz : fixture.ally;
|
|
const leadView = viewGame(fixture.game, fixture.ecaz);
|
|
leadView.players.find(p => p.id === fixture.ecaz)!.bot = difficulty;
|
|
const choose = botActions(leadView).find(a => a.type === 'decision' && a.lead === actor);
|
|
assert.ok(choose, 'Every policy must retain both legal native lead choices.');
|
|
let game = openEcazOccupyPlans(applyAction(fixture.game, fixture.ecaz, choose));
|
|
const view = viewGame(game, actor);
|
|
view.players.find(p => p.id === actor)!.bot = difficulty;
|
|
const plan = botActions(view).find(a => a.type === 'battlePlan');
|
|
assert.ok(plan, 'The actual lead must receive a sealable native candidate.');
|
|
assert.ok(ecazOccupyDialChoices(view).some(c => c.dial === plan.dial && c.support === (plan.support ?? 0)));
|
|
assert.equal(ecazOccupyPlanControl(view, Number(plan.dial), Number(plan.support ?? 0))!.blocked, null);
|
|
game = applyAction(game, actor, plan);
|
|
assert.ok(game.battle!.plans[actor], 'The selected lead, not the force owner, owns the sealed plan.');
|
|
const nonLead = actor === fixture.ecaz ? fixture.ally : fixture.ecaz;
|
|
assert.equal(game.battle!.plans[nonLead], undefined);
|
|
game = applyAction(game, fixture.opponent, { type: 'battlePlan', dial: 0, support: 0,
|
|
leader: leader(game, fixture.opponent, false) });
|
|
for (const voter of viewGame(game, actor).battle!.traitorVoters)
|
|
game = applyAction(game, voter, { type: 'traitorCall', call: false });
|
|
game = aftermath(game);
|
|
assert.equal(game.battle, null);
|
|
assert.equal(game.phase, 7, 'The policy-generated real battle must continue into native Collection.');
|
|
});
|
|
|
|
void test('Basic city survivors get no city income; shared Collection resumes once at its actual decision', () => {
|
|
const city = basicEcazOccupyFixture({ territory: 'arrakeen' });
|
|
const cityResult = resolve(city, 'ecaz');
|
|
assert.equal(cityResult.game.phase, 7);
|
|
for (const id of [city.ecaz, city.ally])
|
|
assert.equal(seat(cityResult.game, id).spice, seat(cityResult.before, id).spice,
|
|
'Basic Arrakeen grants no Advanced two-spice city income.');
|
|
assert.equal(forces(cityResult.game, city, city.ecaz), 2);
|
|
assert.equal(forces(cityResult.game, city, city.ally), 2);
|
|
|
|
const desert = basicEcazOccupyFixture();
|
|
// Controlled bank-funded physical spice after native setup; not a collection receipt.
|
|
desert.game.spice[desert.location] = 5;
|
|
const resolution = resolve(desert, 'ally');
|
|
const before = resolution.before;
|
|
let game = resolution.game;
|
|
assert.equal(game.decision?.kind, 'ecazSpice');
|
|
assert.equal(game.spice[desert.location], 0);
|
|
const pending = reload(game); // Resume only the actual allocation decision.
|
|
game = applyAction(pending, pending.decision!.player,
|
|
{ type: 'decision', event: pending.ecazCollection!.event, allocation: { kind: 'equal' } });
|
|
assert.equal(game.decision, null);
|
|
assert.equal(game.ecazCollection!.stage, 'complete');
|
|
assert.equal(seat(game, desert.ecaz).spice - seat(before, desert.ecaz).spice, 2);
|
|
assert.equal(seat(game, desert.ally).spice - seat(before, desert.ally).spice, 3);
|
|
assert.equal(game.ecazCollection!.settled.length, 1);
|
|
reject(game, pending.decision!.player,
|
|
{ type: 'decision', event: pending.ecazCollection!.event, allocation: { kind: 'equal' } });
|
|
});
|
|
|
|
void test('Basic reciprocal coalition coexists in three shared strongholds and wins through original Mentat', () => {
|
|
const fixture = basicEcazOccupyFixture();
|
|
let game = resolve(fixture, 'ecaz').game;
|
|
game = finishToMovement(advanceToNextStorm(game));
|
|
// Conserved controlled board after the next genuine Storm; no alliance,
|
|
// victory decision, phase or reserve identity is manufactured.
|
|
for (const player of game.players) {
|
|
player.reserves += Object.values(player.forces).reduce((sum, n) => sum + n, 0);
|
|
player.forces = {};
|
|
}
|
|
const cities = TERRITORIES.filter(t => t.type === 'stronghold' &&
|
|
t.sectors.some(sector => sector !== game.storm)).slice(0, 3).map(t => t.id);
|
|
assert.equal(cities.length, 3, 'Three original strongholds remain outside the actual Storm.');
|
|
for (const city of cities) {
|
|
const sector = TERRITORIES.find(t => t.id === city)!.sectors.find(s => s !== game.storm)!;
|
|
assert.ok(sector !== undefined);
|
|
for (const id of [fixture.ecaz, fixture.ally]) {
|
|
seat(game, id).reserves--;
|
|
seat(game, id).forces[locationKey(city, sector)] = 1;
|
|
}
|
|
}
|
|
const progress = viewGame(game, fixture.ecaz).victoryProgress.find(p => p.player === fixture.ecaz)!;
|
|
assert.deepEqual([...progress.jointlyOccupied].sort(), [...cities].sort());
|
|
assert.equal(progress.qualifies, true);
|
|
while (game.phase === 5) {
|
|
game = allowEcazOccupyResponses(game);
|
|
game = applyAction(game, game.active!, { type: 'endMovement' });
|
|
}
|
|
assert.equal(game.phase, 7, 'Shared allied cities must not produce spurious coalition battles.');
|
|
for (let attempt = 0; attempt < 50 && game.status === 'playing'; attempt++) {
|
|
game = allowEcazOccupyResponses(game);
|
|
const actor = game.players.find(p => !game.ready.includes(p.id))!.id;
|
|
game = applyAction(game, actor, { type: 'ready' });
|
|
}
|
|
assert.equal(game.status, 'finished');
|
|
assert.deepEqual([...game.winner].sort(), [fixture.ecaz, fixture.ally].sort());
|
|
});
|