Integrate authorized Advanced Ecaz Occupy combined battles
This commit is contained in:
1 parent
bf4122029e
commit
e112df858a
29 files changed
+1897
-123
No files matched your search
@@ -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' };
|
||||
|
||||
@@ -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 },
|
||||
]);
|
||||
});
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user