Integrate authorized Advanced Ecaz Occupy combined battles

This commit is contained in:
Quintine committed 2026-10-02 13:06:00 +10:00
1 parent bf4122029e
commit e112df858a
29 files changed
+1897 -123

No files matched your search

+29
View File
@@ -6,6 +6,7 @@ import {
quoteBattleBoardContinuation,
quoteBattlePhaseAdvance,
BoardResolutionError,
type BoardContext,
} from '../game/board-resolution-quote';
import {
createGame,
@@ -93,6 +94,34 @@ void test('battle order excludes allies, polar sink and storm-separated forces,
g.players[1].ally = 'a';
assert.deepEqual(quoteBattleBoard(g).battles, []);
});
void test('Advanced whole Ecaz armies cannot recruit storm-isolated allies or split into component battles; Basic is unchanged', () => {
const ecaz = newPlayer('ec', 'Ecaz', 'ecaz');
const ally = newPlayer('al', 'Guild', 'guild');
const enemy = newPlayer('en', 'Emperor', 'emperor');
ecaz.ally = ally.id;
ally.ally = ecaz.id;
ecaz.forces = { 'imperial_basin:9': 1, 'imperial_basin:11': 1 };
ally.forces = { 'imperial_basin:11': 2 };
const context: BoardContext = {
advanced: true, storm: 10, order: ['ec', 'al', 'en'], players: [ecaz, ally, enemy],
};
for (const sector of [9, 11]) {
enemy.forces = { [`imperial_basin:${sector}`]: 2 };
assert.deepEqual(quoteBattleBoard(context).battles, [],
'All coexisting fighters must be clear and connected before the joint battle is offered.');
}
enemy.forces = { 'imperial_basin:9': 2 };
assert.deepEqual(quoteBattleBoard({ ...context, advanced: false }).battles,
[{ territory: 'imperial_basin', attacker: 'ec', defender: 'en' }]);
ecaz.forces = { 'imperial_basin:9': 2 };
ally.forces = { 'imperial_basin:9': 2 };
const clear = quoteBattleBoard(context).battles;
assert.equal(clear.length, 1, 'A clear joint army is one battle, not two faction battles.');
assert.ok(clear[0].coalition?.includes(ecaz.id) && clear[0].coalition.includes(ally.id),
'A represented co-side must retain membership for voluntary advisor conversion.');
assert.deepEqual(quoteBattleBoard({ ...context, storm: 9 }).battles, [],
'A stronghold-safe army in storm still cannot battle.');
});
void test('the moving stronghold uses its pointer sector for storm obstruction and the declared player order', () => {
const g = fixture();
g.mobileStronghold = { location: 'red_chasm:7' };
+237
View File
@@ -0,0 +1,237 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import type { CombatForces } from '../game/combat';
import {
EcazOccupyBattleError,
ecazOccupyDialOptions,
quoteEcazOccupyBattle,
quoteEcazOccupyDial,
quoteEcazOccupyOutcome,
type EcazOccupyBattleInput,
} from '../game/ecaz-occupy-battle';
const ordinary = (normal: number): CombatForces => ({
normal, elite: 0, eliteStrength: 2, freeSupport: false,
});
function battle(
ecazCount: number,
lead = 'ecaz-seat',
canceled = false,
allyForces = ordinary(4),
): EcazOccupyBattleInput {
return {
battleOrderActor: 'enemy-seat',
ecaz: { id: 'ecaz-seat', faction: 'ecaz', ally: 'ally-seat', forces: ordinary(ecazCount) },
ally: { id: 'ally-seat', faction: 'atreides', ally: 'ecaz-seat', forces: allyForces },
lead,
canceled,
};
}
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 dial = quoteEcazOccupyDial(profile, fixed + 1.5, 1);
assert.equal(dial.variableDial, 1.5);
assert.equal(dial.payer, lead);
assert.equal(dial.forceOwner, 'ally-seat');
const win = quoteEcazOccupyOutcome(profile, {
result: 'normal', won: true, dial: fixed + 1.5, support: 1,
});
assert.deepEqual(win.fixedLosses, [{ owner: 'ecaz-seat', normal: fixed, elite: 0 }]);
assert.equal(ecazCount - win.fixedLosses[0].normal, survivors);
assert.equal(win.casualties?.owner, 'ally-seat');
assert.deepEqual(win.casualties?.options, [
{ normal: 2, elite: 0, paidNormal: 1, paidElite: 0 },
]);
assert.equal(4 - win.casualties!.options[0].normal, 2);
assert.deepEqual(win.destroyedArmies, []);
}
}
});
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 loss = quoteEcazOccupyOutcome(profile, {
result: 'normal', won: false, dial: Math.ceil(ecazCount / 2), support: 0,
});
assert.deepEqual(loss, {
destroyedArmies: ['ecaz-seat', 'ally-seat'], fixedLosses: [], casualties: null,
});
}
}
});
void test('Ecaz cannot be dialed below its mandatory increment or charged extra support for it', () => {
const profile = quoteEcazOccupyBattle(battle(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, [
{ normal: 0, elite: 0, paidNormal: 0, paidElite: 0 },
]);
assert.throws(() => quoteEcazOccupyDial(profile, 7.5, 4), /fighter pool/);
assert.throws(() => quoteEcazOccupyDial(profile, 3.25, 0), /fixed Ecaz/);
assert.throws(() => quoteEcazOccupyDial(profile, 4, -1), /native ally-seat support/);
});
void test('Fremen and Fedaykin remain free and physically Fremen even when Ecaz supplies the plan', () => {
const input = battle(3, 'ecaz-seat', false, {
normal: 2, elite: 2, eliteStrength: 2, freeSupport: true,
});
const profile = quoteEcazOccupyBattle({ ...input, ally: { ...input.ally, faction: 'fremen' } });
assert.equal(profile.maxDial, 8);
const quote = quoteEcazOccupyDial(profile, 6, 0);
assert.deepEqual(quote.options, [
{ normal: 2, elite: 1, paidNormal: 0, paidElite: 0 },
{ normal: 0, elite: 2, paidNormal: 0, paidElite: 0 },
]);
assert.equal(quote.forceOwner, 'ally-seat');
assert.equal(quote.payer, 'ecaz-seat');
assert.throws(() => quoteEcazOccupyDial(profile, 6, 1), /fixed Ecaz contribution is free/);
assert.throws(() => quoteEcazOccupyDial(profile, 5.5, 0), /legal physical commitment/);
assert.deepEqual(ecazOccupyDialOptions(profile, 0), [
{ dial: 2, support: 0, variableDial: 0 },
{ dial: 3, support: 0, variableDial: 1 },
{ dial: 4, support: 0, variableDial: 2 },
{ dial: 5, support: 0, variableDial: 3 },
{ dial: 6, support: 0, variableDial: 4 },
{ dial: 7, support: 0, variableDial: 5 },
{ dial: 8, support: 0, variableDial: 6 },
]);
});
void test('Sardaukar keep typed supported casualties and their native opponent-dependent strength', () => {
const input = battle(3, 'ecaz-seat', false, {
normal: 3, elite: 2, eliteStrength: 2, freeSupport: false,
});
const emperor = { ...input.ally, faction: 'emperor' as const };
const profile = quoteEcazOccupyBattle({ ...input, ally: emperor });
const win = quoteEcazOccupyOutcome(profile, {
result: 'normal', won: true, dial: 5, support: 1,
});
assert.equal(win.casualties?.owner, 'ally-seat');
const allocations = win.casualties!.options;
for (const [normal, elite, paidNormal, paidElite] of [
[2, 1, 0, 1], [3, 1, 1, 0], [1, 2, 1, 0],
]) {
assert.ok(allocations.some(option => option.normal === normal && option.elite === elite &&
option.paidNormal === paidNormal && option.paidElite === paidElite),
'Each distinct printed-strength casualty allocation must remain available to the winner.');
}
for (const option of allocations) {
assert.equal(option.paidNormal + option.paidElite, 1);
assert.equal((option.normal + option.paidNormal) / 2 + option.elite + option.paidElite, 3,
'Casualties consume only the three variable strength, not the fixed Ecaz two.');
}
const versusFremen = quoteEcazOccupyBattle({
...input, ally: { ...emperor, forces: { ...emperor.forces, eliteStrength: 1 } },
});
assert.throws(() => quoteEcazOccupyDial(versusFremen, 8, 5), /fighter pool/);
assert.deepEqual(quoteEcazOccupyDial(versusFremen, 3, 1).options, [
{ normal: 1, elite: 0, paidNormal: 1, paidElite: 0 },
{ normal: 0, elite: 1, paidNormal: 0, paidElite: 1 },
]);
});
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 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' } });
const support = lead === 'ecaz-seat' ? 1 : 0;
const win = quoteEcazOccupyOutcome(canceled, {
result: 'normal', won: true, dial: 1, support,
});
assert.equal(canceled.lead, lead);
assert.equal(canceled.battleOrderActor, 'enemy-seat');
assert.equal(win.casualties?.owner, lead);
assert.equal(win.casualties?.dial, 1);
assert.deepEqual(win.fixedLosses, []);
assert.deepEqual(win.destroyedArmies, []);
assert.deepEqual(win.casualties?.options, [{
normal: 1, elite: 0, paidNormal: support, paidElite: 0,
}]);
if (lead === 'ecaz-seat')
assert.deepEqual(quoteEcazOccupyDial(canceled, 1, 0).options, [
{ normal: 2, elite: 0, paidNormal: 0, paidElite: 0 },
]);
else assert.throws(() => quoteEcazOccupyDial(canceled, 1, 1), /native ally-seat support/);
assert.deepEqual(quoteEcazOccupyOutcome(canceled, {
result: 'normal', won: false, dial: 1, support,
}).destroyedArmies, ['ecaz-seat', 'ally-seat']);
}
});
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 dial = canceled ? 0 : 3;
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,
}), /no Occupy winner/);
}
}
});
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)));
assert.deepEqual(ecazOccupyDialOptions(profile, 0), [
{ dial: 1, support: 0, variableDial: 0 },
{ dial: 1.5, support: 0, variableDial: 0.5 },
{ dial: 2, support: 0, variableDial: 1 },
]);
assert.deepEqual(ecazOccupyDialOptions(profile, 1), [
{ dial: 1, support: 0, variableDial: 0 },
{ dial: 1.5, support: 0, variableDial: 0.5 },
{ dial: 2, support: 0, variableDial: 1 },
{ dial: 2, support: 1, variableDial: 1 },
{ dial: 2.5, support: 1, variableDial: 1.5 },
]);
assert.throws(() => ecazOccupyDialOptions(profile, -1), /nonnegative whole spice/);
});
void test('nonreciprocal allies, advisors-only or disconnected pools, and foreign lead actors cannot create a combined army', () => {
const input = battle(3);
assert.throws(() => quoteEcazOccupyBattle({
...input, ally: { ...input.ally, ally: null },
}), /reciprocal ally/);
assert.throws(() => quoteEcazOccupyBattle({
...input, ally: { ...input.ally, faction: 'beneGesserit', forces: ordinary(0) },
}), /storm-connected fighters/);
assert.throws(() => quoteEcazOccupyBattle({
...input, ecaz: { ...input.ecaz, forces: ordinary(0) },
}), /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) },
}), 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 outcome = quoteEcazOccupyOutcome(profile, {
result: 'normal', won: true, dial: 30, support: 20,
});
assert.deepEqual(outcome.fixedLosses, [{ owner: 'ecaz-seat', normal: 10, elite: 0 }]);
assert.equal(outcome.casualties?.owner, 'ally-seat');
assert.deepEqual(outcome.casualties?.options, [
{ normal: 20, elite: 0, paidNormal: 20, paidElite: 0 },
]);
});
+127
View File
@@ -0,0 +1,127 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, viewGame } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { controlsLeader } from '../game/leader-control';
import {
ecazOccupyDialChoices,
ecazOccupyLeadOptions,
ecazOccupyOwnProfile,
ecazOccupyPlanControl,
ecazOccupyPolicyActions,
} from '../game/ecaz-occupy-options';
import {
cancelEcazOccupy,
chooseEcazOccupyLead,
ecazOccupyFixture,
openEcazOccupyPlans,
} from './fixture-ecaz-occupy';
void test('Ecaz can choose either real plan owner; other seats cannot choose the lead', () => {
const fixture = ecazOccupyFixture({ allyFaction: 'guild', ecazForces: 3 });
const own = viewGame(fixture.game, fixture.ecaz);
const choices = ecazOccupyLeadOptions(own);
assert.deepEqual(choices.map((choice) => choice.lead), [fixture.ecaz, fixture.ally]);
for (const choice of choices) {
assert.equal(choice.blocked, null);
const game = applyAction(fixture.game, fixture.ecaz, choice.action);
const selected = ecazOccupyOwnProfile(viewGame(game, choice.lead));
assert.equal(selected?.planOwner, choice.lead);
assert.equal(selected?.payer, choice.lead);
assert.equal(selected?.forceOwner, fixture.ally);
assert.equal(selected?.fixedEcazDial, 2);
assert.equal(ecazOccupyOwnProfile(viewGame(game, fixture.opponent)), null);
}
const outsider = viewGame(fixture.game, fixture.ally);
assert.ok(ecazOccupyLeadOptions(outsider).every((choice) => !!choice.blocked));
assert.deepEqual(ecazOccupyPolicyActions(outsider), []);
for (const choice of choices)
assert.throws(() => applyAction(fixture.game, fixture.ally, choice.action));
});
void test('free fixed Ecaz strength is mandatory and cannot borrow the other ally’s spice', () => {
const fixture = ecazOccupyFixture({ allyFaction: 'guild', ecazForces: 3 });
const game = openEcazOccupyPlans(chooseEcazOccupyLead(fixture, 'ecaz'));
// Labelled Spice Bank budget staging: no allied credit or force property changes.
game.players.find((player) => player.id === fixture.ecaz)!.spice = 0;
game.players.find((player) => player.id === fixture.ally)!.spice = 30;
const view = viewGame(game, fixture.ecaz);
const choices = ecazOccupyDialChoices(view);
assert.ok(choices.some((choice) => choice.dial === 2 && choice.support === 0));
assert.ok(choices.some((choice) => choice.dial === 2.5 && choice.support === 0));
assert.ok(choices.every((choice) => choice.support === 0 && choice.dial >= 2));
assert.equal(ecazOccupyPlanControl(view, 2, 0)?.blocked, null);
assert.ok(ecazOccupyPlanControl(view, 1.5, 0)?.blocked);
assert.ok(ecazOccupyPlanControl(view, 3, 1)?.blocked);
assert.deepEqual(ecazOccupyDialChoices(viewGame(game, fixture.ally)), []);
const me = view.players.find((player) => player.id === view.me)!;
const leader = me.leaders.find((entry) => !entry.dead && controlsLeader(me, entry) &&
(!entry.usedAt || entry.usedAt === fixture.territory))!;
assert.ok(leader);
const next = applyAction(game, fixture.ecaz, {
type: 'battlePlan', dial: 2.5, support: 0, leader: leader.id,
weapon: null, defense: null, kwisatz: false,
});
assert.equal(next.battle?.plans[fixture.ecaz].dial, 2.5);
assert.equal(next.battle?.plans[fixture.ecaz].support, 0);
assert.equal(next.players.find((player) => player.id === fixture.ecaz)?.spice, 0);
assert.equal(next.players.find((player) => player.id === fixture.ally)?.spice, 30);
});
void test('native Karama keeps either selected lead but replaces the active combined dial with their own army', () => {
for (const lead of ['ecaz', 'ally'] as const) {
const fixture = ecazOccupyFixture({ allyFaction: 'guild', ecazForces: 3 });
const game = openEcazOccupyPlans(cancelEcazOccupy(fixture, lead));
const actor = lead === 'ecaz' ? fixture.ecaz : fixture.ally;
const view = viewGame(game, actor);
const control = ecazOccupyPlanControl(view, 0, 0)!;
assert.equal(control.profile.canceled, true);
assert.equal(control.profile.planOwner, actor);
assert.equal(control.profile.forceOwner, actor);
assert.equal(control.minimum, 0);
assert.equal(control.fixedEcazDial, 0);
assert.equal(control.maximum, lead === 'ecaz' ? 3 : 4);
assert.equal(control.blocked, null);
assert.ok(ecazOccupyDialChoices(view).some((choice) => choice.dial === 0 && choice.support === 0));
assert.ok(ecazOccupyPlanControl(view, control.maximum + 0.5, 0)?.blocked);
if (lead === 'ecaz') assert.ok(ecazOccupyPlanControl(view, 4, 3)?.blocked);
}
});
void test('typed Fremen and Emperor variable armies retain native legal increments and support', () => {
const fremen = ecazOccupyFixture({ allyFaction: 'fremen', ecazForces: 3 });
const fremenGame = openEcazOccupyPlans(chooseEcazOccupyLead(fremen, 'ecaz'));
const fremenView = viewGame(fremenGame, fremen.ecaz);
const free = ecazOccupyDialChoices(fremenView);
assert.ok(free.some((choice) => choice.variableDial === 6 && choice.dial === 8));
assert.ok(free.every((choice) => choice.support === 0 && Number.isInteger(choice.variableDial)));
assert.ok(ecazOccupyPlanControl(fremenView, 2.5, 0)?.blocked);
assert.ok(ecazOccupyPlanControl(fremenView, 3, 1)?.blocked);
const emperor = ecazOccupyFixture({ allyFaction: 'emperor', ecazForces: 3 });
const emperorGame = openEcazOccupyPlans(chooseEcazOccupyLead(emperor, 'ecaz'));
const emperorView = viewGame(emperorGame, emperor.ecaz);
const paid = ecazOccupyDialChoices(emperorView);
assert.ok(paid.some((choice) => choice.variableDial === 0.5 && choice.support === 0));
assert.ok(paid.some((choice) => choice.variableDial === 6 && choice.support === 4));
assert.equal(ecazOccupyPlanControl(emperorView, 8, 4)?.blocked, null);
});
void test('all four native policies can seal a legal total plan for either lead, active or canceled', () => {
for (const lead of ['ecaz', 'ally'] as const) for (const canceled of [false, true]) {
const fixture = ecazOccupyFixture({ allyFaction: 'guild', ecazForces: 3 });
const game = openEcazOccupyPlans(canceled
? cancelEcazOccupy(fixture, lead) : chooseEcazOccupyLead(fixture, lead));
const actor = lead === 'ecaz' ? fixture.ecaz : fixture.ally;
for (const policy of DIFFICULTIES) {
const view = viewGame(game, actor);
view.players.find((player) => player.id === actor)!.bot = policy;
const plan = botActions(view).find((action) => action.type === 'battlePlan');
assert.ok(plan, `${policy}/${lead}/${canceled}: native policy must supply a sealable plan`);
assert.equal(ecazOccupyPlanControl(view, Number(plan.dial), Number(plan.support ?? 0))?.blocked, null);
const next = applyAction(game, actor, plan);
assert.ok(viewGame(next, actor).battle?.submitted.includes(actor),
`${policy}/${lead}/${canceled}: the native table must show the selected lead's sealed plan`);
}
}
});
+277
View File
@@ -0,0 +1,277 @@
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);
});
+246
View File
@@ -0,0 +1,246 @@
import assert from 'node:assert/strict';
import { applyAction, createGame, initializeEcazOccupyGameForAudit, joinGame, newPlayer,
viewGame, type Action, type Game, type Player } from '../game/engine';
import { botActions } from '../game/bots';
import { TERRITORIES, location as locationKey, splitLocation } from '../game/board';
import { createAdvancedSourceFixture, finishToMovement, advanceToNextStorm } from './fixture-advanced-source';
import type { FactionId } from '../game/catalog';
import { baseDeck } from '../game/cards';
export type EcazOccupyFixtureOptions = {
initial?: Game;
ecazForces?: number;
allyFaction?: 'fremen' | 'emperor' | 'beneGesserit' | 'guild';
order?: 'ecaz-first' | 'ally-first' | 'opponent-first';
opponentFaction?: 'guild' | 'emperor' | 'beneGesserit' | 'fremen';
advisorAlly?: boolean;
};
export type EcazOccupyFixture = {
game: Game; ecaz: string; ally: string; opponent: string;
territory: string; location: string; ecazForces: number;
};
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.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 && ['advisor', 'intrusion', 'advisorBattle'].includes(game.decision.kind))
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 actions = botActions(view);
const action = actions.find(a => a.type === 'ready') ?? actions[0];
if (action) return applyAction(game, player.id, action);
}
throw new Error(`No native fixture action at ${game.status}/${game.turn}/${game.phase}/${game.decision?.kind}`);
}
export function settleEcazOccupyArrival(state: Game, accompany = false): Game {
let game = state;
for (let attempt = 0; attempt < 50 && (game.response || game.decision); attempt++) {
if (accompany && game.decision?.kind === 'advisor')
game = applyAction(game, game.decision.player,
{ type: 'decision', accept: true, accompany: true, amount: 1 });
else game = step(game);
}
assert.ok(!game.response && !game.decision, 'Native shipment reactions must complete.');
return game;
}
/** Conserved physical board staging AFTER genuine faction setup. No faction,
* alliance, phase, plan, force identity or battle receipt is manufactured. */
function returnBoardToReserves(game: Game): void {
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 = {};
}
if (player.advisors) player.advisors = {};
// The Spice Bank supplies a labelled test budget, not alliance credit.
player.spice = 30;
}
}
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 && card.territory !== 'the_great_flat'));
assert.ok(index >= position, 'The actual unplayed Spice Card must remain available.');
game.spiceDeck.splice(position, 0, game.spiceDeck.splice(index, 1)[0]);
}
/** Conserved physical card exchange after real setup; retains the native draw
* and every original identity. Used only to expose a named rule case. */
export function stageEcazOccupyCard(game: Game, owner: string,
predicate: (card: Player['hand'][number]) => boolean): string {
const player = game.players.find(p => p.id === owner)!;
const held = player.hand.find(predicate);
if (held) return held.id;
const index = game.deck.findIndex(predicate);
let card: Player['hand'][number];
if (index >= 0) {
card = game.deck.splice(index, 1)[0];
if (player.hand.length >= 4) game.deck.push(player.hand.shift()!);
} else {
const donor = game.players.find(p => p.id !== owner && p.hand.some(predicate));
assert.ok(donor, 'A conserved original physical card must supply this rule case.');
const donorIndex = donor.hand.findIndex(predicate);
card = donor.hand[donorIndex];
const replacement = player.hand.shift() ?? game.deck.shift();
assert.ok(replacement, 'A conserved physical face must preserve the donor hand.');
donor.hand[donorIndex] = replacement;
}
player.hand.push(card);
return card.id;
}
/** Genuine fresh E3 faction setup, a natural worm Nexus, reciprocal alliance,
* accepted shared shipment and actual chooseBattle. Returns BEFORE lead choice.
* A supplied fresh setup retains its original deal and printed seat positions. */
export function ecazOccupyFixture(options: EcazOccupyFixtureOptions = {}): EcazOccupyFixture {
const allyFaction = options.allyFaction ?? 'fremen';
const opponentFaction = options.opponentFaction ?? (allyFaction === 'guild' ? 'emperor' : 'guild');
assert.notEqual(allyFaction, opponentFaction);
const ecazForces = options.ecazForces ?? 3;
assert.ok(Number.isInteger(ecazForces) && ecazForces >= 1 && ecazForces <= 5);
let setup: Game;
if (options.initial) {
setup = structuredClone(options.initial);
assert.equal(setup.status, 'setup', 'Captured input must be its original admitted fresh setup.');
assert.equal(setup.ecazOccupyPreview, true);
assert.equal(setup.advanced, true);
assert.deepEqual(setup.expansions, ['ecaz']);
assert.equal(setup.turn, 1, 'Captured Occupy input is never a started-game retrofit.');
assert.ok(!setup.ecazTreachery && !setup.homeworlds && !setup.nexusCards &&
!setup.leaderSkills && !setup.strongholdCards && !setup.techTokens &&
!setup.discoveryEnabled && !setup.discoveries && !setup.discoveryStash &&
!setup.greatMaker && !setup.moritaniAssassinatePreview,
'Captured Occupy inputs admit the explicit ordinary Advanced profile only.');
assert.deepEqual([...setup.deck, ...setup.discard, ...setup.players.flatMap(p => p.hand)]
.map(card => card.id).sort(), baseDeck().map(card => card.id).sort(),
'The original captured deal must retain the ordinary 33-card source.');
} else {
const roster: FactionId[] = ['ecaz', allyFaction, opponentFaction];
setup = createGame('ECAZOCCUPY', newPlayer('ecaz', 'Ecaz', 'ecaz'), true, ['ecaz']);
for (const faction of roster.slice(1)) joinGame(setup, newPlayer(faction, faction, faction));
const desired = options.order === 'ally-first' ? [allyFaction, opponentFaction, 'ecaz'] :
options.order === 'opponent-first' || opponentFaction === 'beneGesserit' ?
[opponentFaction, 'ecaz', allyFaction] : ['ecaz', allyFaction, opponentFaction];
// Circles 3..5 remain in this order for every genuine second-turn Storm
// distance (1..6); the occupied initial circles are vacated via real actions.
for (let i = desired.length - 1; i >= 0; i--)
if (viewGame(setup, desired[i]).playerPositions[desired[i]] !== i + 3)
setup = applyAction(setup, desired[i], { type: 'seatPosition', position: i + 3 });
for (const player of setup.players) setup = applyAction(setup, player.id, { type: 'ready' });
setup = initializeEcazOccupyGameForAudit(setup);
}
assert.ok(setup.players.some(p => p.faction === 'ecaz') &&
setup.players.some(p => p.faction === allyFaction) &&
setup.players.some(p => p.faction === opponentFaction),
'The original source roster must contain the three actual factions for this rule case.');
const ecaz = setup.players.find(p => p.faction === 'ecaz')!.id;
const ally = setup.players.find(p => p.faction === allyFaction)!.id;
const opponent = setup.players.find(p => p.faction === opponentFaction)!.id;
let game = createAdvancedSourceFixture({ initial: setup }).game;
assert.equal(game.status, 'playing');
returnBoardToReserves(game);
orderBlow(game, false, 0);
orderBlow(game, false, 1);
game = finishToMovement(game);
while (game.phase === 5) 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, 'Real Nexus formation must reach native Shipment and Movement.');
assert.equal(game.players.find(p => p.id === ecaz)!.ally, ally);
const 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);
stageEcazOccupyCard(game, opponent, card => card.effect === 'karama');
while (game.phase === 5) {
const actor = game.active!;
if (![ecaz, ally, opponent].includes(actor)) {
game = applyAction(game, actor, { type: 'endMovement' });
continue;
}
const player = game.players.find(p => p.id === actor)!;
const amount = actor === ecaz ? ecazForces : actor === ally ? 4 : 8;
const elite = actor === ally && player.elites ? 2 : 0;
const action: Action = { type: 'ship', territory, sector, amount, ...(elite ? { elite } : {}) };
game = settleEcazOccupyArrival(applyAction(game, actor, action), !!options.advisorAlly && actor === ecaz);
if (actor === ally && options.advisorAlly) {
assert.equal(player.faction, 'beneGesserit');
assert.ok(game.players.find(p => p.id === ally)!.advisors?.[territory]);
}
game = applyAction(game, actor, { type: 'endMovement' });
}
while (game.phaseOpening || game.response || game.decision) game = step(game);
assert.equal(game.phase, 6);
const choices = viewGame(game, game.active!).battleChoices;
const choice = choices.find(b => b.territory === territory)!;
assert.ok(choice, 'The real three-army territory must quote a native battle.');
const chooser = choice.chooser;
game = applyAction(game, chooser, { type: 'chooseBattle', territory,
target: choice.attacker === chooser ? choice.defender : choice.attacker });
if (!options.advisorAlly) assert.equal(game.decision?.kind, 'ecazBattleLead');
return { game, ecaz, ally, opponent, territory, location, ecazForces };
}
/** Resolve only existing native counter windows, never create one. */
export function allowEcazOccupyResponses(state: Game): Game {
let game = state;
for (let attempt = 0; attempt < 100 && game.response; attempt++)
game = applyAction(game, game.players.find(p => !game.response!.passed.includes(p.id))!.id,
{ type: 'passResponse' });
assert.equal(game.response, null);
return game;
}
export function chooseEcazOccupyLead(fixture: EcazOccupyFixture, lead: 'ecaz' | 'ally'): Game {
const decision = fixture.game.decision;
assert.ok(decision?.kind === 'ecazBattleLead', 'Only the native Ecaz lead decision may select this plan owner.');
return applyAction(fixture.game, fixture.ecaz,
{ type: 'decision', event: decision.event, lead: lead === 'ecaz' ? fixture.ecaz : fixture.ally });
}
export function cancelEcazOccupy(fixture: EcazOccupyFixture, lead: 'ecaz' | 'ally', bg = false): Game {
let game = fixture.game;
const card = stageEcazOccupyCard(game, fixture.opponent,
c => bg ? c.kind === 'worthless' : c.effect === 'karama');
game = chooseEcazOccupyLead({ ...fixture, game }, lead);
assert.equal(game.response?.kind, 'ecazOccupy');
return applyAction(game, fixture.opponent, { type: 'card', mode: 'cancel', card });
}
export function openEcazOccupyPlans(state: Game): Game {
let game = allowEcazOccupyResponses(state);
for (let attempt = 0; attempt < 20 && game.battle?.preparation; attempt++) {
game = applyAction(game, game.battle.preparation.owner, { type: 'declineBattlePower' });
game = allowEcazOccupyResponses(game);
}
assert.ok(game.battle && !game.battle.preparation && !game.decision);
return game;
}
export function ecazOccupyTerritoryForces(game: Game, player: string, territory: string): number {
return Object.entries(game.players.find(p => p.id === player)!.forces)
.filter(([key]) => splitLocation(key).territory === territory).reduce((sum, [, n]) => sum + n, 0);
}