390 lines
18 KiB
TypeScript
390 lines
18 KiB
TypeScript
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 },
|
||
]);
|
||
});
|