394 lines
14 KiB
TypeScript
394 lines
14 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import {
|
|
applyAction,
|
|
createGame,
|
|
joinGame,
|
|
newPlayer,
|
|
viewGame,
|
|
type Game,
|
|
} from '../game/engine';
|
|
import { baseDeck, leaders } from '../game/cards';
|
|
import { casualtyOptions } from '../game/combat';
|
|
import { botActions } from '../game/bots';
|
|
import { DIFFICULTIES } from '../game/bot-profiles';
|
|
function fixture(advanced = false) {
|
|
let g = createGame('IXFORCE2', newPlayer('e', 'Emperor', 'emperor'));
|
|
joinGame(g, newPlayer('i', 'Ixians', 'guild'));
|
|
g.players.forEach((p) => {
|
|
p.ready = true;
|
|
});
|
|
g = applyAction(g, 'e', { type: 'start' });
|
|
for (const p of g.players)
|
|
g = applyAction(g, p.id, { type: 'traitor', leader: p.traitorChoices[0] });
|
|
const ix = g.players[1];
|
|
ix.faction = 'ixians';
|
|
ix.leaders = leaders('ixians');
|
|
ix.elites = {
|
|
reserves: 5,
|
|
tanks: 0,
|
|
forces: { 'arrakeen:10': 2 },
|
|
revived: 0,
|
|
};
|
|
g.players.forEach((p) => {
|
|
p.hand = [];
|
|
p.traitors = [];
|
|
p.spice = 20;
|
|
p.forces = { 'arrakeen:10': 8 };
|
|
p.reserves = 12;
|
|
});
|
|
g.phase = 6;
|
|
g.advanced = advanced;
|
|
g.active = 'i';
|
|
g.order = ['i', 'e'];
|
|
g.storm = 18;
|
|
return g;
|
|
}
|
|
function allow(state: Game) {
|
|
let g = state;
|
|
while (g.response)
|
|
g = applyAction(
|
|
g,
|
|
g.players.find((p) => !g.response!.passed.includes(p.id))!.id,
|
|
{ type: 'passResponse' },
|
|
);
|
|
return g;
|
|
}
|
|
function prepare(state: Game) {
|
|
return allow(
|
|
applyAction(state, 'i', {
|
|
type: 'chooseBattle',
|
|
territory: 'arrakeen',
|
|
target: 'e',
|
|
}),
|
|
);
|
|
}
|
|
function battle(state: Game, dial = 6, support = 0) {
|
|
let g = prepare(state);
|
|
g = applyAction(g, 'i', {
|
|
type: 'battlePlan',
|
|
dial,
|
|
support,
|
|
leader: 'ixians-1',
|
|
});
|
|
g = applyAction(g, 'e', { type: 'battlePlan', dial: 0, leader: 'emperor-4' });
|
|
g = applyAction(g, 'i', { type: 'traitorCall', call: false });
|
|
return applyAction(g, 'e', { type: 'traitorCall', call: false });
|
|
}
|
|
function chooseLoss(state: Game, normal = 4, elite = 2) {
|
|
if (state.decision?.kind === 'ixSubstitution') {
|
|
const ix = state.players.find((p) => p.id === 'i')!;
|
|
assert.equal(
|
|
ix.tanks,
|
|
normal + elite,
|
|
'the unique casualty allocation was applied automatically',
|
|
);
|
|
assert.equal(ix.elites?.tanks, elite);
|
|
assert.equal(state.log.at(-1)?.automatic?.name, 'Battle casualties');
|
|
return state;
|
|
}
|
|
assert.equal(state.decision?.kind, 'battleLosses');
|
|
const d = state.decision;
|
|
if (d?.kind !== 'battleLosses') throw Error('Missing casualties');
|
|
const choice = d.options.findIndex(
|
|
(o) => o.normal === normal && o.elite === elite,
|
|
);
|
|
assert.ok(choice >= 0);
|
|
return applyAction(state, 'i', { type: 'decision', choice });
|
|
}
|
|
const substitute = (g: Game, count = 2) =>
|
|
applyAction(g, 'i', {
|
|
type: 'decision',
|
|
sources: { 'arrakeen:10': count },
|
|
recover: { 'arrakeen:10': count },
|
|
});
|
|
function conserved(g: Game) {
|
|
for (const p of g.players)
|
|
assert.equal(
|
|
p.reserves + p.tanks + Object.values(p.forces).reduce((a, b) => a + b, 0),
|
|
20,
|
|
);
|
|
const e = g.players[1].elites!;
|
|
assert.equal(
|
|
e.reserves + e.tanks + Object.values(e.forces).reduce((a, b) => a + b, 0),
|
|
7,
|
|
);
|
|
assert.ok(g.players.every((p) => p.spice >= 0));
|
|
}
|
|
void test('Ixian lobby records initialize seven cyborgs inside twenty physical forces', () => {
|
|
const p = newPlayer('i', 'Ixians', 'ixians');
|
|
assert.equal(p.reserves, 20);
|
|
assert.equal(p.elites?.reserves, 7);
|
|
});
|
|
void test('printed six-strength example applies two cyborg and four suboid casualties in basic and advanced combat', () => {
|
|
for (const advanced of [false, true]) {
|
|
const g = chooseLoss(battle(fixture(advanced), 6, advanced ? 2 : 0));
|
|
assert.equal(g.decision?.kind, 'ixSubstitution');
|
|
assert.equal(g.players[1].forces['arrakeen:10'], 2);
|
|
assert.equal(g.players[1].elites?.tanks, 2);
|
|
assert.equal(g.players[1].tanks, 6);
|
|
assert.equal(g.players[1].spice, advanced ? 18 : 20);
|
|
conserved(g);
|
|
}
|
|
});
|
|
void test('suboids never gain spice strength, including advanced battles and all-suboid armies', () => {
|
|
const forces = {
|
|
normal: 6,
|
|
normalFixedHalf: true,
|
|
elite: 2,
|
|
eliteStrength: 2 as const,
|
|
freeSupport: false,
|
|
};
|
|
assert.deepEqual(casualtyOptions(forces, 6, 3), []);
|
|
assert.deepEqual(casualtyOptions({ ...forces, elite: 0 }, 3, 1), []);
|
|
assert.equal(casualtyOptions({ ...forces, elite: 0 }, 3, 0).length, 1);
|
|
const g = prepare(fixture(true));
|
|
const snapshot = structuredClone(g);
|
|
assert.throws(
|
|
() =>
|
|
applyAction(g, 'i', {
|
|
type: 'battlePlan',
|
|
dial: 6,
|
|
support: 3,
|
|
leader: 'ixians-1',
|
|
}),
|
|
/legal force/,
|
|
);
|
|
assert.deepEqual(g, snapshot);
|
|
});
|
|
void test('basic Ixians can seal half-strength dials while ordinary basic factions cannot', () => {
|
|
let g = prepare(fixture());
|
|
g = applyAction(g, 'i', {
|
|
type: 'battlePlan',
|
|
dial: 0.5,
|
|
leader: 'ixians-1',
|
|
});
|
|
assert.equal(g.battle?.plans.i.dial, 0.5);
|
|
assert.throws(
|
|
() =>
|
|
applyAction(g, 'e', {
|
|
type: 'battlePlan',
|
|
dial: 0.5,
|
|
leader: 'emperor-4',
|
|
}),
|
|
/integer/,
|
|
);
|
|
});
|
|
void test('advanced cyborgs count one unsupported and two supported while suboids remain one-half', () => {
|
|
const f = {
|
|
normal: 0,
|
|
normalFixedHalf: true,
|
|
elite: 2,
|
|
eliteStrength: 2 as const,
|
|
freeSupport: false,
|
|
};
|
|
assert.deepEqual(casualtyOptions(f, 2, 0), [
|
|
{ normal: 0, elite: 2, paidNormal: 0, paidElite: 0 },
|
|
]);
|
|
assert.deepEqual(casualtyOptions(f, 2, 1), [
|
|
{ normal: 0, elite: 1, paidNormal: 0, paidElite: 1 },
|
|
]);
|
|
assert.equal(casualtyOptions(f, 4, 2).length, 1);
|
|
});
|
|
void test('substitution exchanges physical types, preserves total casualties and completes after its response', () => {
|
|
let g = chooseLoss(battle(fixture()));
|
|
g.players[0].hand = [baseDeck().find((c) => c.effect === 'karama')!];
|
|
const before = structuredClone(g);
|
|
g = substitute(g);
|
|
assert.equal(g.response?.kind, 'ixSubstitution');
|
|
assert.deepEqual(g.players[1].elites, before.players[1].elites);
|
|
g = allow(JSON.parse(JSON.stringify(g)));
|
|
assert.equal(g.players[1].forces['arrakeen:10'], 2);
|
|
assert.equal(g.players[1].elites?.forces['arrakeen:10'], 2);
|
|
assert.equal(g.players[1].elites?.tanks, 0);
|
|
assert.equal(g.players[1].tanks, 6);
|
|
assert.equal(g.players[1].battleLosses, 6);
|
|
assert.equal(g.pendingIxSubstitution, null);
|
|
conserved(g);
|
|
});
|
|
void test('Karama cancels one selected substitution and leaves original cyborg casualties in the tanks', () => {
|
|
const before = fixture();
|
|
const karama = baseDeck().find((c) => c.effect === 'karama')!;
|
|
// Move this physical fixture card out of the shuffled supply before holding it.
|
|
before.deck = before.deck.filter((card) => card.id !== karama.id);
|
|
before.discard = before.discard.filter((card) => card.id !== karama.id);
|
|
before.players[0].hand = [karama];
|
|
let g = substitute(chooseLoss(battle(before)));
|
|
g = applyAction(g, 'e', { type: 'card', card: karama.id, mode: 'cancel' });
|
|
assert.equal(g.players[1].elites?.tanks, 2);
|
|
assert.equal(g.players[1].elites?.forces['arrakeen:10'], undefined);
|
|
assert.equal(g.players[1].forces['arrakeen:10'], 2);
|
|
assert.equal(g.pendingIxSubstitution, null);
|
|
conserved(g);
|
|
});
|
|
void test('substitution is optional and partial exchanges preserve remaining cyborg deaths', () => {
|
|
const before = chooseLoss(battle(fixture()));
|
|
const declined = applyAction(before, 'i', {
|
|
type: 'decision',
|
|
decline: true,
|
|
});
|
|
assert.equal(declined.players[1].elites?.tanks, 2);
|
|
const partial = allow(substitute(before, 1));
|
|
assert.equal(partial.players[1].elites?.tanks, 1);
|
|
assert.equal(partial.players[1].elites?.forces['arrakeen:10'], 1);
|
|
conserved(partial);
|
|
});
|
|
void test('wrong ownership, excessive counts, mismatched exchanges and unrelated sectors reject atomically', () => {
|
|
const g = chooseLoss(battle(fixture()));
|
|
const snapshot = structuredClone(g);
|
|
assert.throws(
|
|
() => applyAction(g, 'e', { type: 'decision', decline: true }),
|
|
/pending decision/,
|
|
);
|
|
assert.throws(() => substitute(g, 3), /integer/);
|
|
for (const action of [
|
|
{ sources: { 'arrakeen:10': 2 }, recover: { 'arrakeen:10': 1 } },
|
|
{ sources: { 'carthag:11': 1 }, recover: { 'arrakeen:10': 1 } },
|
|
{ sources: { 'arrakeen:10': 1 }, recover: { 'carthag:11': 1 } },
|
|
{ sources: [], recover: {} },
|
|
])
|
|
assert.throws(
|
|
() => applyAction(g, 'i', { type: 'decision', ...action }),
|
|
/sector|Exchange/,
|
|
);
|
|
assert.deepEqual(g, snapshot);
|
|
});
|
|
void test('substitution retains cyborgs in their casualty sectors and allows a surviving suboid in another sector of that territory', () => {
|
|
const before = fixture();
|
|
before.players[1].forces = { 'imperial_basin:10': 4, 'imperial_basin:9': 4 };
|
|
before.players[1].elites!.forces = { 'imperial_basin:10': 2 };
|
|
// The pending aftermath fixture retains the exact casualty-sector ledger produced by combat.
|
|
before.players[1].forces = { 'imperial_basin:9': 2 };
|
|
before.players[1].tanks = 6;
|
|
before.players[1].elites!.forces = {};
|
|
before.players[1].elites!.tanks = 2;
|
|
before.players[1].battleLosses = 6;
|
|
before.pendingIxSubstitution = {
|
|
player: 'i',
|
|
territory: 'imperial_basin',
|
|
losses: { 'imperial_basin:10': 2 },
|
|
cards: [],
|
|
};
|
|
before.decision = {
|
|
kind: 'ixSubstitution',
|
|
player: 'i',
|
|
territory: 'imperial_basin',
|
|
losses: { 'imperial_basin:10': 2 },
|
|
};
|
|
const g = allow(
|
|
applyAction(before, 'i', {
|
|
type: 'decision',
|
|
sources: { 'imperial_basin:9': 2 },
|
|
recover: { 'imperial_basin:10': 2 },
|
|
}),
|
|
);
|
|
assert.equal(g.players[1].forces['imperial_basin:9'], undefined);
|
|
assert.equal(g.players[1].elites!.forces['imperial_basin:10'], 2);
|
|
conserved(g);
|
|
});
|
|
void test('basic cyborg strength has a Karama window; advanced suboid strength has no cancellable benefit', () => {
|
|
const before = fixture();
|
|
const karama = baseDeck().find((c) => c.effect === 'karama')!;
|
|
before.players[0].hand = [karama];
|
|
let g = applyAction(before, 'i', {
|
|
type: 'chooseBattle',
|
|
territory: 'arrakeen',
|
|
target: 'e',
|
|
});
|
|
assert.equal(g.response?.kind, 'eliteStrength');
|
|
g = applyAction(g, 'e', { type: 'card', card: karama.id, mode: 'cancel' });
|
|
assert.throws(
|
|
() =>
|
|
applyAction(g, 'i', { type: 'battlePlan', dial: 6, leader: 'ixians-1' }),
|
|
/legal force/,
|
|
);
|
|
assert.doesNotThrow(() =>
|
|
applyAction(g, 'i', { type: 'battlePlan', dial: 5, leader: 'ixians-1' }),
|
|
);
|
|
const advanced = applyAction(fixture(true), 'i', {
|
|
type: 'chooseBattle',
|
|
territory: 'arrakeen',
|
|
target: 'e',
|
|
});
|
|
assert.equal(advanced.response, null);
|
|
});
|
|
void test('traitor destruction leaves no survivors to substitute and conserves cyborgs', () => {
|
|
let g = prepare(fixture());
|
|
g.players[0].traitors = ['ixians-1'];
|
|
g = applyAction(g, 'i', { type: 'battlePlan', dial: 0, leader: 'ixians-1' });
|
|
g = applyAction(g, 'e', { type: 'battlePlan', dial: 0, leader: 'emperor-4' });
|
|
g = applyAction(g, 'i', { type: 'traitorCall', call: false });
|
|
g = applyAction(g, 'e', { type: 'traitorCall', call: true });
|
|
assert.notEqual(g.decision?.kind, 'ixSubstitution');
|
|
assert.equal(g.players[1].elites!.tanks, 2);
|
|
conserved(g);
|
|
});
|
|
void test('all AI levels seal legal basic/advanced Ixian plans and make legal substitutions', () => {
|
|
for (const advanced of [false, true])
|
|
for (const difficulty of DIFFICULTIES) {
|
|
const before = fixture(advanced);
|
|
before.players[1].bot = difficulty;
|
|
const g = prepare(before);
|
|
const actions = botActions(viewGame(g, 'i')).filter(
|
|
(action) => action.type === 'battlePlan',
|
|
);
|
|
assert.ok(actions.length);
|
|
assert.doesNotThrow(() => applyAction(g, 'i', actions[0]));
|
|
const aftermath = chooseLoss(battle(before, 6, advanced ? 2 : 0));
|
|
const action = botActions(viewGame(aftermath, 'i'))[0];
|
|
const result = allow(applyAction(aftermath, 'i', action));
|
|
conserved(result);
|
|
assert.ok(result.players[1].elites!.tanks < 2);
|
|
}
|
|
});
|
|
void test('Ixian casualty solver matches exhaustive independent suboid/cyborg assignments', () => {
|
|
for (const basic of [false, true])
|
|
for (const strength of [1, 2] as const)
|
|
for (let support = 0; support <= 4; support++)
|
|
for (let half = 0; half <= 16; half++) {
|
|
const expected = new Set<string>();
|
|
for (let suboids = 0; suboids <= 4; suboids++)
|
|
for (let cyborgs = 0; cyborgs <= 3; cyborgs++)
|
|
for (let paid = 0; paid <= cyborgs; paid++) {
|
|
if (basic ? support !== 0 || paid !== 0 : paid !== support)
|
|
continue;
|
|
const dial =
|
|
suboids / 2 +
|
|
(basic
|
|
? cyborgs * strength
|
|
: ((cyborgs - paid) * strength) / 2 + paid * strength);
|
|
if (dial === half / 2) expected.add(`${suboids}:${cyborgs}`);
|
|
}
|
|
const actual = new Set(
|
|
casualtyOptions(
|
|
{
|
|
normal: 4,
|
|
normalFixedHalf: true,
|
|
elite: 3,
|
|
eliteStrength: strength,
|
|
freeSupport: basic,
|
|
},
|
|
half / 2,
|
|
support,
|
|
).map((o) => `${o.normal}:${o.elite}`),
|
|
);
|
|
assert.deepEqual(
|
|
actual,
|
|
expected,
|
|
`${basic}/${strength}/${support}/${half}`,
|
|
);
|
|
}
|
|
});
|
|
|
|
void test('changing away from Ixians in the lobby removes their force types before a base game starts', () => {
|
|
let g = createGame('IXLOBBY2', newPlayer('i', 'Player', 'ixians'));
|
|
joinGame(g, newPlayer('e', 'Emperor', 'emperor'));
|
|
g = applyAction(g, 'i', { type: 'faction', faction: 'atreides' });
|
|
assert.equal(g.players[0].elites, undefined);
|
|
g.players.forEach((p) => {
|
|
p.ready = true;
|
|
});
|
|
g = applyAction(g, 'i', { type: 'start' });
|
|
assert.ok(g.players.every((p) => !p.elites));
|
|
});
|