Compose source-clear Basic even-force Ecaz Occupy
This commit is contained in:
1 parent
63e57bd894
commit
5f082696a6
18 files changed
+861
-52
No files matched your search
@@ -0,0 +1,306 @@
|
||||
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, basicEcazOccupyBattleAction } 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 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);
|
||||
});
|
||||
|
||||
void test('Basic odd uncanceled native choice rejects before creating a lead frame or committing forces', () => {
|
||||
const fixture = basicEcazOccupyFixture({ ecazForces: 3, chooseBattle: false });
|
||||
const { actor, action } = basicEcazOccupyBattleAction(fixture);
|
||||
reject(fixture.game, actor, action);
|
||||
assert.equal(fixture.game.battle, null);
|
||||
assert.equal(fixture.game.decision, null);
|
||||
assert.equal(forces(fixture.game, fixture, fixture.ecaz), 3);
|
||||
assert.equal(forces(fixture.game, fixture, fixture.ally), 4);
|
||||
});
|
||||
|
||||
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());
|
||||
});
|
||||
@@ -10,30 +10,183 @@ import {
|
||||
type EcazOccupyBattleInput,
|
||||
} from '../game/ecaz-occupy-battle';
|
||||
|
||||
const ordinary = (normal: number): CombatForces => ({
|
||||
normal, elite: 0, eliteStrength: 2, freeSupport: false,
|
||||
const ordinary = (normal: number, advanced: boolean): CombatForces => ({
|
||||
normal, elite: 0, eliteStrength: advanced ? 2 : 1, freeSupport: !advanced,
|
||||
});
|
||||
function battle(
|
||||
advanced: boolean,
|
||||
ecazCount: number,
|
||||
lead = 'ecaz-seat',
|
||||
canceled = false,
|
||||
allyForces = ordinary(4),
|
||||
allyForces = ordinary(4, advanced),
|
||||
): EcazOccupyBattleInput {
|
||||
return {
|
||||
advanced,
|
||||
battleOrderActor: 'enemy-seat',
|
||||
ecaz: { id: 'ecaz-seat', faction: 'ecaz', ally: 'ally-seat', forces: ordinary(ecazCount) },
|
||||
ecaz: { id: 'ecaz-seat', faction: 'ecaz', ally: 'ally-seat', forces: ordinary(ecazCount, advanced) },
|
||||
ally: { id: 'ally-seat', faction: 'atreides', ally: 'ecaz-seat', forces: allyForces },
|
||||
lead,
|
||||
canceled,
|
||||
};
|
||||
}
|
||||
|
||||
void test('Basic E2/E4 normal wins split Ecaz equally and settle the real ally pool with either lead', () => {
|
||||
for (const ecazCount of [2, 4]) {
|
||||
for (const lead of ['ecaz-seat', 'ally-seat']) {
|
||||
const profile = quoteEcazOccupyBattle(battle(false, ecazCount, lead));
|
||||
const fixed = ecazCount / 2;
|
||||
for (const variableDial of [0, 2]) {
|
||||
const dial = quoteEcazOccupyDial(profile, fixed + variableDial, 0);
|
||||
assert.equal(dial.payer, lead);
|
||||
assert.equal(dial.forceOwner, 'ally-seat');
|
||||
const win = quoteEcazOccupyOutcome(profile, {
|
||||
result: 'normal', won: true, dial: fixed + variableDial, support: 0,
|
||||
});
|
||||
assert.deepEqual(win.fixedLosses, [{ owner: 'ecaz-seat', normal: fixed, elite: 0 }]);
|
||||
assert.equal(ecazCount - win.fixedLosses[0].normal, fixed);
|
||||
assert.equal(win.casualties?.owner, 'ally-seat');
|
||||
assert.deepEqual(win.casualties?.options, [
|
||||
{ normal: variableDial, elite: 0, paidNormal: 0, paidElite: 0 },
|
||||
]);
|
||||
assert.equal(4 - win.casualties!.options[0].normal, 4 - variableDial);
|
||||
assert.deepEqual(win.destroyedArmies, []);
|
||||
assert.deepEqual(quoteEcazOccupyOutcome(profile, {
|
||||
result: 'normal', won: false, dial: fixed + variableDial, support: 0,
|
||||
}), {
|
||||
destroyedArmies: ['ecaz-seat', 'ally-seat'], fixedLosses: [], casualties: null,
|
||||
});
|
||||
}
|
||||
assert.deepEqual(ecazOccupyDialOptions(profile, 0), [
|
||||
{ dial: fixed, support: 0, variableDial: 0 },
|
||||
{ dial: fixed + 1, support: 0, variableDial: 1 },
|
||||
{ dial: fixed + 2, support: 0, variableDial: 2 },
|
||||
{ dial: fixed + 3, support: 0, variableDial: 3 },
|
||||
{ dial: fixed + 4, support: 0, variableDial: 4 },
|
||||
]);
|
||||
assert.throws(() => quoteEcazOccupyDial(profile, fixed - 1, 0), EcazOccupyBattleError);
|
||||
assert.throws(() => quoteEcazOccupyDial(profile, fixed + 0.5, 0), EcazOccupyBattleError);
|
||||
assert.throws(() => quoteEcazOccupyDial(profile, fixed + 1, 1), EcazOccupyBattleError);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
void test('Basic Fedaykin and Sardaukar stay typed, full strength one, free, and physically allied under either lead', () => {
|
||||
const forces: CombatForces = { normal: 2, elite: 2, eliteStrength: 1, freeSupport: true };
|
||||
for (const faction of ['fremen', 'emperor'] as const) {
|
||||
for (const ecazCount of [2, 4]) {
|
||||
for (const lead of ['ecaz-seat', 'ally-seat']) {
|
||||
const input = battle(false, ecazCount, lead, false, forces);
|
||||
const profile = quoteEcazOccupyBattle({ ...input, ally: { ...input.ally, faction } });
|
||||
const fixed = ecazCount / 2;
|
||||
const win = quoteEcazOccupyOutcome(profile, {
|
||||
result: 'normal', won: true, dial: fixed + 2, support: 0,
|
||||
});
|
||||
assert.deepEqual(win.fixedLosses, [{ owner: 'ecaz-seat', normal: fixed, elite: 0 }]);
|
||||
assert.equal(win.casualties?.owner, 'ally-seat');
|
||||
assert.deepEqual(win.casualties?.options, [
|
||||
{ normal: 2, elite: 0, paidNormal: 0, paidElite: 0 },
|
||||
{ normal: 1, elite: 1, paidNormal: 0, paidElite: 0 },
|
||||
{ normal: 0, elite: 2, paidNormal: 0, paidElite: 0 },
|
||||
]);
|
||||
assert.deepEqual(quoteEcazOccupyDial(profile, fixed + 4, 0).options, [
|
||||
{ normal: 2, elite: 2, paidNormal: 0, paidElite: 0 },
|
||||
]);
|
||||
assert.throws(() => quoteEcazOccupyDial(profile, fixed + 5, 0), EcazOccupyBattleError);
|
||||
assert.throws(() => quoteEcazOccupyDial(profile, fixed + 2, 1), EcazOccupyBattleError);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
void test('Basic cancellation uses the selected own pool even for odd Ecaz and preserves the other army on a normal win', () => {
|
||||
const forces: CombatForces = { normal: 0, elite: 2, eliteStrength: 1, freeSupport: true };
|
||||
for (const ecazCount of [2, 4, 5]) {
|
||||
for (const lead of ['ecaz-seat', 'ally-seat']) {
|
||||
const input = battle(false, ecazCount, lead, true, forces);
|
||||
const profile = quoteEcazOccupyBattle({
|
||||
...input, ally: { ...input.ally, faction: 'emperor' },
|
||||
});
|
||||
const dial = quoteEcazOccupyDial(profile, 1, 0);
|
||||
assert.equal(dial.forceOwner, lead);
|
||||
assert.equal(dial.payer, lead);
|
||||
assert.equal(dial.fixedEcazDial, 0);
|
||||
assert.deepEqual(dial.options, [{
|
||||
normal: lead === 'ecaz-seat' ? 1 : 0,
|
||||
elite: lead === 'ally-seat' ? 1 : 0,
|
||||
paidNormal: 0, paidElite: 0,
|
||||
}]);
|
||||
const win = quoteEcazOccupyOutcome(profile, {
|
||||
result: 'normal', won: true, dial: 1, support: 0,
|
||||
});
|
||||
assert.equal(win.casualties?.owner, lead);
|
||||
assert.deepEqual(win.fixedLosses, []);
|
||||
assert.deepEqual(win.destroyedArmies, []);
|
||||
assert.deepEqual(quoteEcazOccupyOutcome(profile, {
|
||||
result: 'normal', won: false, dial: 1, support: 0,
|
||||
}), {
|
||||
destroyedArmies: ['ecaz-seat', 'ally-seat'], fixedLosses: [], casualties: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
const input = battle(false, 5, 'ecaz-seat', true);
|
||||
assert.throws(() => quoteEcazOccupyBattle({
|
||||
...input, ally: { ...input.ally, forces: ordinary(0, false) },
|
||||
}), EcazOccupyBattleError);
|
||||
assert.throws(() => quoteEcazOccupyBattle({
|
||||
...input, ecaz: { ...input.ecaz, forces: ordinary(0, false) },
|
||||
}), EcazOccupyBattleError);
|
||||
});
|
||||
|
||||
void test('Basic sole traitor victory spares both armies; defeat, mutual traitors and explosion spare neither', () => {
|
||||
for (const ecazCount of [2, 4]) {
|
||||
for (const lead of ['ecaz-seat', 'ally-seat']) {
|
||||
for (const canceled of [false, true]) {
|
||||
const profile = quoteEcazOccupyBattle(battle(false, ecazCount, lead, canceled));
|
||||
const dial = canceled ? 1 : ecazCount / 2 + 1;
|
||||
assert.deepEqual(quoteEcazOccupyOutcome(profile, {
|
||||
result: 'traitor', won: true, dial, support: 0,
|
||||
}), { destroyedArmies: [], fixedLosses: [], casualties: null });
|
||||
for (const result of ['traitor', 'mutualTraitors', 'explosion'] as const)
|
||||
assert.deepEqual(quoteEcazOccupyOutcome(profile, {
|
||||
result, won: false, dial, support: 0,
|
||||
}), {
|
||||
destroyedArmies: ['ecaz-seat', 'ally-seat'], fixedLosses: [], casualties: null,
|
||||
});
|
||||
for (const result of ['mutualTraitors', 'explosion'] as const)
|
||||
assert.throws(() => quoteEcazOccupyOutcome(profile, {
|
||||
result, won: true, dial, support: 0,
|
||||
}), EcazOccupyBattleError);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
void test('uncanceled Basic odd Ecaz is source-blocked before commitment, while Advanced keeps ceil losses and floor survivors', () => {
|
||||
for (const ecazCount of [1, 3, 5, 19]) {
|
||||
for (const lead of ['ecaz-seat', 'ally-seat']) {
|
||||
assert.throws(() => quoteEcazOccupyBattle(battle(false, ecazCount, lead)), EcazOccupyBattleError);
|
||||
const profile = quoteEcazOccupyBattle(battle(true, ecazCount, lead));
|
||||
const win = quoteEcazOccupyOutcome(profile, {
|
||||
result: 'normal', won: true, dial: Math.ceil(ecazCount / 2), support: 0,
|
||||
});
|
||||
assert.equal(ecazCount - win.fixedLosses[0].normal, Math.floor(ecazCount / 2));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
void test('the Occupy rules band is required rather than defaulting malformed or missing input to Advanced', () => {
|
||||
for (const advanced of [undefined, null, 0, 1, 'false', 'true']) {
|
||||
const input = { ...battle(true, 4), advanced } as unknown as EcazOccupyBattleInput;
|
||||
assert.throws(() => quoteEcazOccupyBattle(input), EcazOccupyBattleError);
|
||||
}
|
||||
});
|
||||
|
||||
void test('Advanced E1–5 ordinary wins lose ceil Ecaz separately and retain floor, with either lead', () => {
|
||||
for (const [ecazCount, fixed, survivors] of [
|
||||
[1, 1, 0], [2, 1, 1], [3, 2, 1], [4, 2, 2], [5, 3, 2],
|
||||
]) {
|
||||
for (const lead of ['ecaz-seat', 'ally-seat']) {
|
||||
const profile = quoteEcazOccupyBattle(battle(ecazCount, lead));
|
||||
const profile = quoteEcazOccupyBattle(battle(true, ecazCount, lead));
|
||||
const dial = quoteEcazOccupyDial(profile, fixed + 1.5, 1);
|
||||
assert.equal(dial.variableDial, 1.5);
|
||||
assert.equal(dial.payer, lead);
|
||||
@@ -56,7 +209,7 @@ void test('Advanced E1–5 ordinary wins lose ceil Ecaz separately and retain fl
|
||||
void test('Advanced E1–5 normal defeat destroys both real armies regardless of the selected lead', () => {
|
||||
for (const ecazCount of [1, 2, 3, 4, 5]) {
|
||||
for (const lead of ['ecaz-seat', 'ally-seat']) {
|
||||
const profile = quoteEcazOccupyBattle(battle(ecazCount, lead));
|
||||
const profile = quoteEcazOccupyBattle(battle(true, ecazCount, lead));
|
||||
const loss = quoteEcazOccupyOutcome(profile, {
|
||||
result: 'normal', won: false, dial: Math.ceil(ecazCount / 2), support: 0,
|
||||
});
|
||||
@@ -68,7 +221,7 @@ void test('Advanced E1–5 normal defeat destroys both real armies regardless of
|
||||
});
|
||||
|
||||
void test('Ecaz cannot be dialed below its mandatory increment or charged extra support for it', () => {
|
||||
const profile = quoteEcazOccupyBattle(battle(5));
|
||||
const profile = quoteEcazOccupyBattle(battle(true, 5));
|
||||
assert.throws(() => quoteEcazOccupyDial(profile, 2.5, 0), /include 3 fixed Ecaz/);
|
||||
assert.throws(() => quoteEcazOccupyDial(profile, 3, 1), /legal physical commitment/);
|
||||
assert.deepEqual(quoteEcazOccupyDial(profile, 3, 0).options, [
|
||||
@@ -80,7 +233,7 @@ void test('Ecaz cannot be dialed below its mandatory increment or charged extra
|
||||
});
|
||||
|
||||
void test('Fremen and Fedaykin remain free and physically Fremen even when Ecaz supplies the plan', () => {
|
||||
const input = battle(3, 'ecaz-seat', false, {
|
||||
const input = battle(true, 3, 'ecaz-seat', false, {
|
||||
normal: 2, elite: 2, eliteStrength: 2, freeSupport: true,
|
||||
});
|
||||
const profile = quoteEcazOccupyBattle({ ...input, ally: { ...input.ally, faction: 'fremen' } });
|
||||
@@ -106,7 +259,7 @@ void test('Fremen and Fedaykin remain free and physically Fremen even when Ecaz
|
||||
});
|
||||
|
||||
void test('Sardaukar keep typed supported casualties and their native opponent-dependent strength', () => {
|
||||
const input = battle(3, 'ecaz-seat', false, {
|
||||
const input = battle(true, 3, 'ecaz-seat', false, {
|
||||
normal: 3, elite: 2, eliteStrength: 2, freeSupport: false,
|
||||
});
|
||||
const emperor = { ...input.ally, faction: 'emperor' as const };
|
||||
@@ -141,7 +294,7 @@ void test('Sardaukar keep typed supported casualties and their native opponent-d
|
||||
void test('cancellation retains either selected lead, recomputes its own dial, and leaves the other army undialed on a win', () => {
|
||||
const fremen: CombatForces = { normal: 2, elite: 2, eliteStrength: 2, freeSupport: true };
|
||||
for (const lead of ['ecaz-seat', 'ally-seat']) {
|
||||
const input = battle(5, lead, false, fremen);
|
||||
const input = battle(true, 5, lead, false, fremen);
|
||||
const active = quoteEcazOccupyBattle({ ...input, ally: { ...input.ally, faction: 'fremen' } });
|
||||
assert.equal(quoteEcazOccupyDial(active, 4, 0).forceOwner, 'ally-seat');
|
||||
const canceled = quoteEcazOccupyBattle({ ...input, canceled: true, ally: { ...input.ally, faction: 'fremen' } });
|
||||
@@ -172,7 +325,7 @@ void test('cancellation retains either selected lead, recomputes its own dial, a
|
||||
void test('sole traitor victory waives both pools losses; sole defeat, mutual traitors and explosion destroy the actual side', () => {
|
||||
for (const lead of ['ecaz-seat', 'ally-seat']) {
|
||||
for (const canceled of [false, true]) {
|
||||
const profile = quoteEcazOccupyBattle(battle(5, lead, canceled));
|
||||
const profile = quoteEcazOccupyBattle(battle(true, 5, lead, canceled));
|
||||
const dial = canceled ? 0 : 3;
|
||||
assert.deepEqual(quoteEcazOccupyOutcome(profile, {
|
||||
result: 'traitor', won: true, dial, support: 0,
|
||||
@@ -190,7 +343,7 @@ void test('sole traitor victory waives both pools losses; sole defeat, mutual tr
|
||||
});
|
||||
|
||||
void test('funding-limited legal options retain unsupported half dials without spending the fixed contribution', () => {
|
||||
const profile = quoteEcazOccupyBattle(battle(1, 'ecaz-seat', false, ordinary(2)));
|
||||
const profile = quoteEcazOccupyBattle(battle(true, 1, 'ecaz-seat', false, ordinary(2, true)));
|
||||
assert.deepEqual(ecazOccupyDialOptions(profile, 0), [
|
||||
{ dial: 1, support: 0, variableDial: 0 },
|
||||
{ dial: 1.5, support: 0, variableDial: 0.5 },
|
||||
@@ -207,25 +360,25 @@ void test('funding-limited legal options retain unsupported half dials without s
|
||||
});
|
||||
|
||||
void test('nonreciprocal allies, advisors-only or disconnected pools, and foreign lead actors cannot create a combined army', () => {
|
||||
const input = battle(3);
|
||||
const input = battle(true, 3);
|
||||
assert.throws(() => quoteEcazOccupyBattle({
|
||||
...input, ally: { ...input.ally, ally: null },
|
||||
}), /reciprocal ally/);
|
||||
assert.throws(() => quoteEcazOccupyBattle({
|
||||
...input, ally: { ...input.ally, faction: 'beneGesserit', forces: ordinary(0) },
|
||||
...input, ally: { ...input.ally, faction: 'beneGesserit', forces: ordinary(0, true) },
|
||||
}), /storm-connected fighters/);
|
||||
assert.throws(() => quoteEcazOccupyBattle({
|
||||
...input, ecaz: { ...input.ecaz, forces: ordinary(0) },
|
||||
...input, ecaz: { ...input.ecaz, forces: ordinary(0, true) },
|
||||
}), /storm-connected fighters/);
|
||||
assert.throws(() => quoteEcazOccupyBattle({ ...input, lead: 'enemy-seat' }), /lead must be Ecaz/);
|
||||
assert.throws(() => quoteEcazOccupyBattle({ ...input, battleOrderActor: '' }), /battle-order actor/);
|
||||
assert.throws(() => quoteEcazOccupyBattle({
|
||||
...input, ally: { ...input.ally, forces: ordinary(21) },
|
||||
...input, ally: { ...input.ally, forces: ordinary(21, true) },
|
||||
}), EcazOccupyBattleError);
|
||||
});
|
||||
|
||||
void test('two twenty-counter armies are legal separate pools, not an over-cap merged CombatForces', () => {
|
||||
const profile = quoteEcazOccupyBattle(battle(20, 'ecaz-seat', false, ordinary(20)));
|
||||
const profile = quoteEcazOccupyBattle(battle(true, 20, 'ecaz-seat', false, ordinary(20, true)));
|
||||
const outcome = quoteEcazOccupyOutcome(profile, {
|
||||
result: 'normal', won: true, dial: 30, support: 20,
|
||||
});
|
||||
|
||||
@@ -28,6 +28,7 @@ function profile(
|
||||
allyForces = native(4),
|
||||
): EcazOccupyBattleProfile {
|
||||
return quoteEcazOccupyBattle({
|
||||
advanced: true,
|
||||
battleOrderActor: 'ecaz-user',
|
||||
ecaz: {
|
||||
id: 'ecaz-user', faction: 'ecaz', ally: 'ally-user',
|
||||
|
||||
@@ -132,7 +132,7 @@ for (const role of ['ally', 'opponent'] as const) for (const marker of [0, 3, 5]
|
||||
|
||||
void test('Original concealed Richese presence permits a zero prospective pool, never an ordinary empty ally', () => {
|
||||
const forces = { normal: 0, elite: 0, eliteStrength: 2 as const, freeSupport: false };
|
||||
const input: EcazOccupyBattleInput = { battleOrderActor: 'ecaz', lead: 'ecaz', canceled: false,
|
||||
const input: EcazOccupyBattleInput = { advanced: true, battleOrderActor: 'ecaz', lead: 'ecaz', canceled: false,
|
||||
ecaz: { id: 'ecaz', faction: 'ecaz', ally: 'richese', forces: { ...forces, normal: 5 } },
|
||||
ally: { id: 'richese', faction: 'richese', ally: 'ecaz', forces } };
|
||||
const saved = structuredClone(input);
|
||||
|
||||
@@ -16,6 +16,7 @@ const ix: CombatForces = {
|
||||
};
|
||||
function profile(ecazNormal = 5, lead = 'ecaz-user', canceled = false, allyForces = ix): EcazOccupyBattleProfile {
|
||||
return quoteEcazOccupyBattle({
|
||||
advanced: true,
|
||||
battleOrderActor: 'ecaz-user',
|
||||
ecaz: { id: 'ecaz-user', faction: 'ecaz', ally: 'ally-user', forces: ordinary(ecazNormal) },
|
||||
ally: { id: 'ally-user', faction: 'ixians', ally: 'ecaz-user', forces: allyForces },
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, createGame, initializeEcazOccupyGameForAudit, joinGame, newPlayer,
|
||||
viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { TERRITORIES, location as locationKey } from '../game/board';
|
||||
import { treacheryDeck } from '../game/cards';
|
||||
import { advanceToNextStorm } from './fixture-advanced-source';
|
||||
import { finishEcazOccupySetup, settleEcazOccupyArrival, stageEcazOccupyCard,
|
||||
type EcazOccupyFixture } from './fixture-ecaz-occupy';
|
||||
|
||||
export type BasicEcazOccupyOptions = {
|
||||
/** Existing authenticated ready lobby; never recreate seats or ready it again. */
|
||||
initial?: Game;
|
||||
ecazForces?: number;
|
||||
allyFaction?: 'guild' | 'emperor' | 'fremen';
|
||||
opponentFaction?: 'guild' | 'emperor' | 'fremen';
|
||||
allyForces?: number;
|
||||
territory?: string;
|
||||
/** Odd rule probes stop before the native chooseBattle commitment. */
|
||||
chooseBattle?: boolean;
|
||||
};
|
||||
|
||||
function step(game: Game): Game {
|
||||
if (game.response) return applyAction(game,
|
||||
game.players.find(p => !game.response!.passed.includes(p.id))!.id, { type: 'passResponse' });
|
||||
if (game.phaseOpening) return applyAction(game,
|
||||
game.players.find(p => !game.phaseOpening!.passed.includes(p.id))!.id, { type: 'ready' });
|
||||
if (game.decision?.kind === 'ecazPlacement')
|
||||
return applyAction(game, game.decision.player, { type: 'decision', decline: true });
|
||||
if (game.phase === 0 && !game.decision && game.stormPending === null) {
|
||||
const dialer = game.stormDialers.find(id => game.stormDials[id] === undefined)!;
|
||||
return applyAction(game, dialer, { type: 'stormDial', amount: game.turn === 1 ? 0 : 1 });
|
||||
}
|
||||
if (game.phase === 3 && game.auction && !game.decision)
|
||||
return applyAction(game, game.auction.active, { type: 'passBid' });
|
||||
if (game.decision?.kind === 'wormRide')
|
||||
return applyAction(game, game.decision.player, { type: 'decision', accept: false });
|
||||
if (game.decision?.kind === 'guildShipment')
|
||||
return applyAction(game, game.decision.player, { type: 'decision', allow: true });
|
||||
for (const player of game.players) {
|
||||
const view = viewGame(game, player.id);
|
||||
view.players.find(p => p.id === player.id)!.bot = 'Easy';
|
||||
const candidates = botActions(view);
|
||||
const action = candidates.find(a => a.type === 'ready') ?? candidates[0];
|
||||
if (action) return applyAction(game, player.id, action);
|
||||
}
|
||||
throw new Error(`No native Basic action at ${game.status}/${game.turn}/${game.phase}/${game.decision?.kind}`);
|
||||
}
|
||||
|
||||
function orderBlow(game: Game, worm: boolean, position: number): void {
|
||||
const index = game.spiceDeck.findIndex((card, i) => i >= position &&
|
||||
(worm ? 'worm' in card && !card.greatMaker && !card.suppressed :
|
||||
'territory' in card && !['the_great_flat', 'arrakeen'].includes(card.territory)));
|
||||
assert.ok(index >= position, 'Reorder only original unplayed Spice Cards.');
|
||||
game.spiceDeck.splice(position, 0, game.spiceDeck.splice(index, 1)[0]);
|
||||
}
|
||||
|
||||
export function basicEcazOccupyBattleAction(fixture: EcazOccupyFixture): { actor: string; action: Action } {
|
||||
const game = fixture.game;
|
||||
const choice = viewGame(game, game.active!).battleChoices.find(b => b.territory === fixture.territory);
|
||||
assert.ok(choice, 'The physical coalition and rival must produce one native battle choice.');
|
||||
return { actor: choice.chooser, action: { type: 'chooseBattle', territory: fixture.territory,
|
||||
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker } };
|
||||
}
|
||||
|
||||
/** Genuine Basic native deal, original Storm/Blow/Nexus/alliance and shipments.
|
||||
* Only physical board forces and unplayed card order are controlled after setup.
|
||||
* Native wallets, phases, reciprocal alliance, plans and receipts remain original. */
|
||||
export function basicEcazOccupyFixture(options: BasicEcazOccupyOptions = {}): EcazOccupyFixture {
|
||||
const allyFaction = options.allyFaction ?? 'guild';
|
||||
const opponentFaction = options.opponentFaction ?? (allyFaction === 'guild' ? 'emperor' : 'guild');
|
||||
assert.notEqual(allyFaction, opponentFaction);
|
||||
let setup: Game;
|
||||
if (options.initial) {
|
||||
setup = structuredClone(options.initial);
|
||||
} else {
|
||||
const roster = ['ecaz', allyFaction, opponentFaction] as const;
|
||||
setup = createGame('BASICECAZOCCUPY', newPlayer('ecaz', 'ecaz', 'ecaz'), false, ['ecaz']);
|
||||
for (const faction of roster.slice(1)) joinGame(setup, newPlayer(faction, faction, faction));
|
||||
for (let i = roster.length - 1; i >= 0; i--)
|
||||
if (viewGame(setup, roster[i]).playerPositions[roster[i]] !== i + 3)
|
||||
setup = applyAction(setup, roster[i], { type: 'seatPosition', position: i + 3 });
|
||||
for (const player of setup.players) setup = applyAction(setup, player.id, { type: 'ready' });
|
||||
}
|
||||
setup = initializeEcazOccupyGameForAudit(setup);
|
||||
assert.equal(setup.advanced, false);
|
||||
assert.deepEqual(setup.expansions, ['ecaz']);
|
||||
assert.ok(!setup.ecazTreachery && !setup.leaderSkills && !setup.homeworlds && !setup.nexusCards &&
|
||||
!setup.strongholdCards && !setup.techTokens && !setup.discoveryEnabled);
|
||||
const ecazSeat = setup.players.find(player => player.faction === 'ecaz');
|
||||
const allySeat = setup.players.find(player => player.faction === allyFaction);
|
||||
const opponentSeat = setup.players.find(player => player.faction === opponentFaction);
|
||||
assert.ok(ecazSeat && allySeat && opponentSeat, 'The actual lobby must contain the requested native factions.');
|
||||
const ecaz = ecazSeat.id, ally = allySeat.id, opponent = opponentSeat.id;
|
||||
const initial = structuredClone(setup);
|
||||
let game = finishEcazOccupySetup(setup);
|
||||
const afterSetup = structuredClone(game);
|
||||
assert.deepEqual([...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand)]
|
||||
.map(c => c.id).sort(), treacheryDeck(['ecaz']).map(c => c.id).sort(),
|
||||
'The original Basic deal retains the exact Ecaz family deck.');
|
||||
for (const player of game.players) {
|
||||
player.reserves += Object.values(player.forces).reduce((sum, n) => sum + n, 0);
|
||||
player.forces = {};
|
||||
if (player.elites) {
|
||||
player.elites.reserves += Object.values(player.elites.forces).reduce((sum, n) => sum + n, 0);
|
||||
player.elites.forces = {};
|
||||
}
|
||||
}
|
||||
orderBlow(game, false, 0);
|
||||
orderBlow(game, false, 1);
|
||||
for (let attempt = 0; attempt < 800 && !(game.phase === 5 &&
|
||||
!game.phaseOpening && !game.response && !game.decision); attempt++) game = step(game);
|
||||
assert.equal(game.phase, 5, 'The first original Basic movement must open.');
|
||||
while (game.phase === 5) game = game.response || game.phaseOpening || game.decision
|
||||
? step(game) : applyAction(game, game.active!, { type: 'endMovement' });
|
||||
game = advanceToNextStorm(game);
|
||||
orderBlow(game, true, 0);
|
||||
orderBlow(game, false, 1);
|
||||
orderBlow(game, false, 2);
|
||||
let allied = false;
|
||||
for (let attempt = 0; attempt < 500; attempt++) {
|
||||
if (!allied && game.phase === 1 && game.nexus && !game.spiceWindow &&
|
||||
!game.spiceResolution && !game.phaseOpening && !game.response && !game.decision) {
|
||||
game = applyAction(game, ecaz, { type: 'alliance', target: ally });
|
||||
game = applyAction(game, ally, { type: 'alliance', target: ecaz });
|
||||
allied = true;
|
||||
}
|
||||
if (game.phase === 5 && !game.phaseOpening && !game.response && !game.decision) break;
|
||||
game = step(game);
|
||||
}
|
||||
assert.ok(allied && game.phase === 5, 'A genuine Basic worm Nexus must form the reciprocal alliance.');
|
||||
assert.equal(game.players.find(p => p.id === ecaz)!.ally, ally);
|
||||
assert.equal(game.players.find(p => p.id === ally)!.ally, ecaz);
|
||||
const territory = options.territory ?? 'the_great_flat';
|
||||
const sector = TERRITORIES.find(t => t.id === territory)!.sectors.find(s => s !== game.storm)!;
|
||||
assert.ok(sector !== undefined);
|
||||
const location = locationKey(territory, sector);
|
||||
const ecazForces = options.ecazForces ?? 4;
|
||||
// Controlled rival position after the genuine Nexus. Transfer eight original
|
||||
// reserve counters without claiming an unaffordable played shipment history.
|
||||
const rival = game.players.find(player => player.id === opponent)!;
|
||||
assert.ok(rival.reserves >= 8);
|
||||
rival.reserves -= 8;
|
||||
rival.forces[location] = 8;
|
||||
stageEcazOccupyCard(game, opponent, card => card.effect === 'karama');
|
||||
while (game.phase === 5) {
|
||||
if (game.response || game.phaseOpening || game.decision) { game = step(game); continue; }
|
||||
const actor = game.active!;
|
||||
if (actor === ecaz || actor === ally) {
|
||||
const amount = actor === ecaz ? ecazForces : options.allyForces ?? 4;
|
||||
game = settleEcazOccupyArrival(applyAction(game, actor,
|
||||
{ type: 'ship', territory, sector, amount }));
|
||||
}
|
||||
game = applyAction(game, actor, { type: 'endMovement' });
|
||||
}
|
||||
while (game.phaseOpening || game.response || game.decision) game = step(game);
|
||||
assert.equal(game.phase, 6);
|
||||
const fixture: EcazOccupyFixture = { game, initial, afterSetup, ecaz, ally,
|
||||
opponent, territory, location, ecazForces };
|
||||
if (options.chooseBattle !== false) {
|
||||
const { actor, action } = basicEcazOccupyBattleAction(fixture);
|
||||
fixture.game = applyAction(game, actor, action);
|
||||
assert.equal(fixture.game.decision?.kind, 'ecazBattleLead');
|
||||
}
|
||||
return fixture;
|
||||
}
|
||||
Reference in new issue
Block a user