Files
Dune/tests/normal-free-support.test.ts

240 lines
8.0 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import {
casualtyOptions,
validCombatForces,
type CombatForces,
} from '../game/combat';
import {
stoneBurnerComparison,
stoneBurnerPlanBlock,
} from '../game/stone-burner';
import {
quoteBattleResolution,
type BattleResolutionInput,
} from '../game/battle-resolution-quote';
import { leaders } from '../game/cards';
const ix = (patch: Partial<CombatForces> = {}): CombatForces => ({
normal: 2,
elite: 1,
eliteStrength: 2,
freeSupport: false,
normalFixedHalf: true,
normalFreeSupport: true,
...patch,
});
void test('full-strength Suboids cost no support while Advanced Cyborgs retain their separate paid and unpaid strength', () => {
assert.deepEqual(casualtyOptions(ix({ normal: 1 }), 3, 1), [
{ normal: 1, elite: 1, paidNormal: 0, paidElite: 1 },
]);
assert.deepEqual(casualtyOptions(ix({ normal: 1 }), 3, 0), []);
assert.deepEqual(casualtyOptions(ix({ normal: 1 }), 2, 0), [
{ normal: 1, elite: 1, paidNormal: 0, paidElite: 0 },
]);
assert.deepEqual(casualtyOptions(ix({ normal: 2, elite: 0 }), 2, 0), [
{ normal: 2, elite: 0, paidNormal: 0, paidElite: 0 },
]);
assert.deepEqual(casualtyOptions(ix({ normal: 2, elite: 0 }), 2, 1), []);
assert.deepEqual(casualtyOptions(ix({ normal: 1 }), 3, 2), []);
});
void test('Basic free support and explicit elite exemption remain independent from the normal exemption', () => {
const basic = ix({ normal: 1, freeSupport: true });
assert.deepEqual(casualtyOptions(basic, 3, 0), [
{ normal: 1, elite: 1, paidNormal: 0, paidElite: 0 },
]);
assert.deepEqual(
casualtyOptions({ ...basic, normalFreeSupport: false }, 2.5, 0),
[{ normal: 1, elite: 1, paidNormal: 0, paidElite: 0 }],
);
assert.deepEqual(
casualtyOptions({ ...basic, normalFreeSupport: false }, 3, 0),
[],
);
assert.deepEqual(
casualtyOptions(ix({ normal: 1, eliteFreeSupport: true }), 3, 0),
[{ normal: 1, elite: 1, paidNormal: 0, paidElite: 0 }],
);
assert.deepEqual(
casualtyOptions(ix({ normal: 1, eliteFreeSupport: true }), 3, 1),
[],
);
});
void test('normal-free-support solver matches independent per-counter assignments for every small mixed pool', () => {
for (const normal of [0, 1, 2, 3])
for (const elite of [0, 1, 2])
for (const eliteStrength of [1, 2] as const)
for (const eliteFreeSupport of [false, true]) {
const expected = new Map<string, Set<string>>();
// Each normal token is either retained or lost at full strength for no cost.
for (let mask = 0; mask < 2 ** normal; mask++)
for (let code = 0; code < 3 ** elite; code++) {
let half = 0,
paid = 0,
n = 0,
e = 0,
remainder = code,
valid = true;
for (let i = 0; i < normal; i++)
if (mask & (1 << i)) {
half += 2;
n++;
}
for (let i = 0; i < elite; i++) {
const choice = remainder % 3;
remainder = Math.floor(remainder / 3);
if (!choice) continue;
e++;
if (eliteFreeSupport) {
if (choice === 2) {
valid = false;
break;
}
half += eliteStrength * 2;
} else {
half += eliteStrength * choice;
if (choice === 2) paid++;
}
}
if (!valid) continue;
const key = `${half}:${paid}`;
if (!expected.has(key)) expected.set(key, new Set());
expected.get(key)!.add(`${n}:${e}`);
}
const pool = ix({ normal, elite, eliteStrength, eliteFreeSupport });
for (
let half = 0;
half <= normal * 2 + elite * eliteStrength * 2;
half++
)
for (let support = 0; support <= elite + 1; support++) {
const actual = casualtyOptions(pool, half / 2, support);
assert.deepEqual(
new Set(actual.map((o) => `${o.normal}:${o.elite}`)),
expected.get(`${half}:${support}`) ?? new Set(),
`${normal}/${elite}/${eliteStrength}/${eliteFreeSupport}/${half}/${support}`,
);
assert.ok(
actual.every(
(o) => o.paidNormal === 0 && o.paidElite === support,
),
);
}
}
});
void test('Stone Burner compares actual survivors with the new normal strength and caches both profiles distinctly', () => {
const defender: CombatForces = {
normal: 1,
elite: 0,
eliteStrength: 1,
freeSupport: true,
};
assert.deepEqual(stoneBurnerComparison(ix(), 2, 0, defender, 0, 0), {
winner: 'attacker',
attacker: [1],
defender: [1],
});
assert.deepEqual(
stoneBurnerComparison(
ix({ normalFreeSupport: false }),
2,
0,
defender,
0,
0,
),
{ winner: 'defender', attacker: [0], defender: [1] },
);
const own = { ...defender, normal: 2 };
for (let repeat = 0; repeat < 2; repeat++) {
assert.match(
stoneBurnerPlanBlock(
own,
1,
0,
ix({ normalFreeSupport: false }),
'attacker',
)!,
/different legal force allocations/,
);
assert.equal(stoneBurnerPlanBlock(own, 1, 0, ix(), 'attacker'), null);
}
});
function resolution(advanced = true): BattleResolutionInput {
const a = [...leaders('ixians')].sort((x, y) => y.strength - x.strength)[0];
const d = [...leaders('guild')].sort((x, y) => x.strength - y.strength)[0];
return {
advanced,
turn: 2,
territory: 'pasty_mesa',
attacker: {
id: 'i',
faction: 'ixians',
spice: 3,
hand: [],
leader: a,
forces: ix({ normal: 1, freeSupport: !advanced }),
plan: {
dial: 3,
support: advanced ? 1 : 0,
leader: a.id,
weapon: null,
defense: null,
},
},
defender: {
id: 'g',
faction: 'guild',
spice: 0,
hand: [],
leader: d,
forces: { normal: 1, elite: 0, eliteStrength: 1, freeSupport: !advanced },
plan: { dial: 0, support: 0, leader: d.id, weapon: null, defense: null },
},
voters: [
{ id: 'i', beneficiary: 'i', called: false, traitors: [] },
{ id: 'g', beneficiary: 'g', called: false, traitors: [] },
],
participants: [
{ id: 'i', faction: 'ixians' },
{ id: 'g', faction: 'guild' },
],
physicalCards: [],
pendingAuditorPresent: false,
pendingRetentionPresent: false,
};
}
void test('battle resolution charges only the Cyborg and preserves the typed force profile in winner casualties', () => {
for (const advanced of [false, true]) {
const input = resolution(advanced),
before = structuredClone(input),
q = quoteBattleResolution(input);
assert.equal(q.winner, 'i');
assert.equal(
q.payments.find((p) => p.player === 'i')?.cost ?? 0,
advanced ? 1 : 0,
);
assert.equal(q.casualties!.forces.normalFreeSupport, true);
assert.deepEqual(q.casualties!.options, [
{ normal: 1, elite: 1, paidNormal: 0, paidElite: advanced ? 1 : 0 },
]);
assert.deepEqual(input, before);
}
});
void test('malformed normal exemption flags reject in combat, Stone Burner and resolution without mutation', () => {
for (const flag of ['yes', 1, null]) {
const malformed = { ...ix(), normalFreeSupport: flag } as unknown as CombatForces;
assert.equal(validCombatForces(malformed), false);
assert.deepEqual(casualtyOptions(malformed, 3, 1), []);
assert.match(
stoneBurnerPlanBlock(malformed, 3, 1, ix(), 'attacker')!,
/valid supported physical/,
);
const input = resolution();
input.attacker.forces = malformed;
const before = structuredClone(input);
assert.throws(() => quoteBattleResolution(input), /valid force/);
assert.deepEqual(input, before);
}
});