Files
Dune/tests/ecaz-occupy-battle.test.ts

390 lines
18 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, advanced: boolean): CombatForces => ({
normal, elite: 0, eliteStrength: advanced ? 2 : 1, freeSupport: !advanced,
});
function battle(
advanced: boolean,
ecazCount: number,
lead = 'ecaz-seat',
canceled = false,
allyForces = ordinary(4, advanced),
): EcazOccupyBattleInput {
return {
advanced,
battleOrderActor: 'enemy-seat',
ecaz: { id: 'ecaz-seat', faction: 'ecaz', ally: 'ally-seat', forces: ordinary(ecazCount, advanced) },
ally: { id: 'ally-seat', faction: 'atreides', ally: 'ecaz-seat', forces: allyForces },
lead,
canceled,
};
}
void test('Basic E2/E4 normal wins split Ecaz equally and settle the real ally pool with either lead', () => {
for (const ecazCount of [2, 4]) {
for (const lead of ['ecaz-seat', 'ally-seat']) {
const profile = quoteEcazOccupyBattle(battle(false, ecazCount, lead));
const fixed = ecazCount / 2;
for (const variableDial of [0, 2]) {
const dial = quoteEcazOccupyDial(profile, fixed + variableDial, 0);
assert.equal(dial.payer, lead);
assert.equal(dial.forceOwner, 'ally-seat');
const win = quoteEcazOccupyOutcome(profile, {
result: 'normal', won: true, dial: fixed + variableDial, support: 0,
});
assert.deepEqual(win.fixedLosses, [{ owner: 'ecaz-seat', normal: fixed, elite: 0 }]);
assert.equal(ecazCount - win.fixedLosses[0].normal, fixed);
assert.equal(win.casualties?.owner, 'ally-seat');
assert.deepEqual(win.casualties?.options, [
{ normal: variableDial, elite: 0, paidNormal: 0, paidElite: 0 },
]);
assert.equal(4 - win.casualties!.options[0].normal, 4 - variableDial);
assert.deepEqual(win.destroyedArmies, []);
assert.deepEqual(quoteEcazOccupyOutcome(profile, {
result: 'normal', won: false, dial: fixed + variableDial, support: 0,
}), {
destroyedArmies: ['ecaz-seat', 'ally-seat'], fixedLosses: [], casualties: null,
});
}
assert.deepEqual(ecazOccupyDialOptions(profile, 0), [
{ dial: fixed, support: 0, variableDial: 0 },
{ dial: fixed + 1, support: 0, variableDial: 1 },
{ dial: fixed + 2, support: 0, variableDial: 2 },
{ dial: fixed + 3, support: 0, variableDial: 3 },
{ dial: fixed + 4, support: 0, variableDial: 4 },
]);
assert.throws(() => quoteEcazOccupyDial(profile, fixed - 1, 0), EcazOccupyBattleError);
assert.throws(() => quoteEcazOccupyDial(profile, fixed + 0.5, 0), EcazOccupyBattleError);
assert.throws(() => quoteEcazOccupyDial(profile, fixed + 1, 1), EcazOccupyBattleError);
}
}
});
void test('Basic Fedaykin and Sardaukar stay typed, full strength one, free, and physically allied under either lead', () => {
const forces: CombatForces = { normal: 2, elite: 2, eliteStrength: 1, freeSupport: true };
for (const faction of ['fremen', 'emperor'] as const) {
for (const ecazCount of [2, 4]) {
for (const lead of ['ecaz-seat', 'ally-seat']) {
const input = battle(false, ecazCount, lead, false, forces);
const profile = quoteEcazOccupyBattle({ ...input, ally: { ...input.ally, faction } });
const fixed = ecazCount / 2;
const win = quoteEcazOccupyOutcome(profile, {
result: 'normal', won: true, dial: fixed + 2, support: 0,
});
assert.deepEqual(win.fixedLosses, [{ owner: 'ecaz-seat', normal: fixed, elite: 0 }]);
assert.equal(win.casualties?.owner, 'ally-seat');
assert.deepEqual(win.casualties?.options, [
{ normal: 2, elite: 0, paidNormal: 0, paidElite: 0 },
{ normal: 1, elite: 1, paidNormal: 0, paidElite: 0 },
{ normal: 0, elite: 2, paidNormal: 0, paidElite: 0 },
]);
assert.deepEqual(quoteEcazOccupyDial(profile, fixed + 4, 0).options, [
{ normal: 2, elite: 2, paidNormal: 0, paidElite: 0 },
]);
assert.throws(() => quoteEcazOccupyDial(profile, fixed + 5, 0), EcazOccupyBattleError);
assert.throws(() => quoteEcazOccupyDial(profile, fixed + 2, 1), EcazOccupyBattleError);
}
}
}
});
void test('Basic cancellation uses the selected own pool even for odd Ecaz and preserves the other army on a normal win', () => {
const forces: CombatForces = { normal: 0, elite: 2, eliteStrength: 1, freeSupport: true };
for (const ecazCount of [2, 4, 5]) {
for (const lead of ['ecaz-seat', 'ally-seat']) {
const input = battle(false, ecazCount, lead, true, forces);
const profile = quoteEcazOccupyBattle({
...input, ally: { ...input.ally, faction: 'emperor' },
});
const dial = quoteEcazOccupyDial(profile, 1, 0);
assert.equal(dial.forceOwner, lead);
assert.equal(dial.payer, lead);
assert.equal(dial.fixedEcazDial, 0);
assert.deepEqual(dial.options, [{
normal: lead === 'ecaz-seat' ? 1 : 0,
elite: lead === 'ally-seat' ? 1 : 0,
paidNormal: 0, paidElite: 0,
}]);
const win = quoteEcazOccupyOutcome(profile, {
result: 'normal', won: true, dial: 1, support: 0,
});
assert.equal(win.casualties?.owner, lead);
assert.deepEqual(win.fixedLosses, []);
assert.deepEqual(win.destroyedArmies, []);
assert.deepEqual(quoteEcazOccupyOutcome(profile, {
result: 'normal', won: false, dial: 1, support: 0,
}), {
destroyedArmies: ['ecaz-seat', 'ally-seat'], fixedLosses: [], casualties: null,
});
}
}
const input = battle(false, 5, 'ecaz-seat', true);
assert.throws(() => quoteEcazOccupyBattle({
...input, ally: { ...input.ally, forces: ordinary(0, false) },
}), EcazOccupyBattleError);
assert.throws(() => quoteEcazOccupyBattle({
...input, ecaz: { ...input.ecaz, forces: ordinary(0, false) },
}), EcazOccupyBattleError);
});
void test('Basic sole traitor victory spares both armies; defeat, mutual traitors and explosion spare neither', () => {
for (const ecazCount of [2, 4]) {
for (const lead of ['ecaz-seat', 'ally-seat']) {
for (const canceled of [false, true]) {
const profile = quoteEcazOccupyBattle(battle(false, ecazCount, lead, canceled));
const dial = canceled ? 1 : ecazCount / 2 + 1;
assert.deepEqual(quoteEcazOccupyOutcome(profile, {
result: 'traitor', won: true, dial, support: 0,
}), { destroyedArmies: [], fixedLosses: [], casualties: null });
for (const result of ['traitor', 'mutualTraitors', 'explosion'] as const)
assert.deepEqual(quoteEcazOccupyOutcome(profile, {
result, won: false, dial, support: 0,
}), {
destroyedArmies: ['ecaz-seat', 'ally-seat'], fixedLosses: [], casualties: null,
});
for (const result of ['mutualTraitors', 'explosion'] as const)
assert.throws(() => quoteEcazOccupyOutcome(profile, {
result, won: true, dial, support: 0,
}), EcazOccupyBattleError);
}
}
}
});
void test('Advanced odd Ecaz keeps ceil losses and floor survivors', () => {
for (const ecazCount of [1, 3, 5, 19]) {
for (const lead of ['ecaz-seat', 'ally-seat']) {
const profile = quoteEcazOccupyBattle(battle(true, ecazCount, lead));
const win = quoteEcazOccupyOutcome(profile, {
result: 'normal', won: true, dial: Math.ceil(ecazCount / 2), support: 0,
});
assert.equal(ecazCount - win.fixedLosses[0].normal, Math.floor(ecazCount / 2));
}
}
});
void test('the Occupy rules band is required rather than defaulting malformed or missing input to Advanced', () => {
for (const advanced of [undefined, null, 0, 1, 'false', 'true']) {
const input = { ...battle(true, 4), advanced } as unknown as EcazOccupyBattleInput;
assert.throws(() => quoteEcazOccupyBattle(input), EcazOccupyBattleError);
}
});
void test('Advanced E1–5 ordinary wins lose ceil Ecaz separately and retain floor, with either lead', () => {
for (const [ecazCount, fixed, survivors] of [
[1, 1, 0], [2, 1, 1], [3, 2, 1], [4, 2, 2], [5, 3, 2],
]) {
for (const lead of ['ecaz-seat', 'ally-seat']) {
const profile = quoteEcazOccupyBattle(battle(true, 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(true, 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(true, 5));
assert.throws(() => quoteEcazOccupyDial(profile, 2.5, 0), /include 3 fixed Ecaz/);
assert.throws(() => quoteEcazOccupyDial(profile, 3, 1), /legal physical commitment/);
assert.deepEqual(quoteEcazOccupyDial(profile, 3, 0).options, [
{ 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(true, 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(true, 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(true, 5, lead, false, fremen);
const active = quoteEcazOccupyBattle({ ...input, ally: { ...input.ally, faction: 'fremen' } });
assert.equal(quoteEcazOccupyDial(active, 4, 0).forceOwner, 'ally-seat');
const canceled = quoteEcazOccupyBattle({ ...input, canceled: true, ally: { ...input.ally, faction: 'fremen' } });
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(true, 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(true, 1, 'ecaz-seat', false, ordinary(2, true)));
assert.deepEqual(ecazOccupyDialOptions(profile, 0), [
{ dial: 1, support: 0, variableDial: 0 },
{ dial: 1.5, support: 0, variableDial: 0.5 },
{ 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(true, 3);
assert.throws(() => quoteEcazOccupyBattle({
...input, ally: { ...input.ally, ally: null },
}), /reciprocal ally/);
assert.throws(() => quoteEcazOccupyBattle({
...input, ally: { ...input.ally, faction: 'beneGesserit', forces: ordinary(0, true) },
}), /storm-connected fighters/);
assert.throws(() => quoteEcazOccupyBattle({
...input, ecaz: { ...input.ecaz, forces: ordinary(0, true) },
}), /storm-connected fighters/);
assert.throws(() => quoteEcazOccupyBattle({ ...input, lead: 'enemy-seat' }), /lead must be Ecaz/);
assert.throws(() => quoteEcazOccupyBattle({ ...input, battleOrderActor: '' }), /battle-order actor/);
assert.throws(() => quoteEcazOccupyBattle({
...input, ally: { ...input.ally, forces: ordinary(21, true) },
}), EcazOccupyBattleError);
});
void test('two twenty-counter armies are legal separate pools, not an over-cap merged CombatForces', () => {
const profile = quoteEcazOccupyBattle(battle(true, 20, 'ecaz-seat', false, ordinary(20, true)));
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 },
]);
});