Files
Dune/tests/combat-karama.test.ts
2026-09-08 07:37:52 +10:00

238 lines
7.5 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import {
createGame,
newPlayer,
joinGame,
applyAction,
viewGame,
type Game,
} from '../game/engine';
import { baseDeck } from '../game/cards';
function fixture() {
let g = createGame('COMBATK2', newPlayer('f', 'Fremen', 'fremen'));
joinGame(g, newPlayer('e', 'Emperor', 'emperor'));
joinGame(g, newPlayer('g', 'Guild', 'guild'));
g.players.forEach((p) => (p.ready = true));
g = applyAction(g, 'f', { type: 'start' });
for (const p of g.players)
if (p.traitorChoices.length)
g = applyAction(g, p.id, {
type: 'traitor',
leader: p.traitorChoices[0],
});
g = applyAction(g, 'f', {
type: 'fremenSetup',
placements: { sietch_tabr: 10 },
});
g.advanced = true;
g.phase = 6;
g.active = 'f';
g.order = ['f', 'e', 'g'];
g.storm = 18;
for (const p of g.players) {
p.forces = p.id === 'g' ? {} : { 'red_chasm:7': 3 };
p.reserves = p.id === 'g' ? 20 : 17;
p.spice = 10;
p.hand = [];
p.traitors = [];
}
g.players[0].elites = {
reserves: 2,
tanks: 0,
forces: { 'red_chasm:7': 1 },
revived: 0,
};
g.players[1].elites = {
reserves: 4,
tanks: 0,
forces: { 'red_chasm:7': 1 },
revived: 0,
};
g.players[2].hand = baseDeck().filter((c) => c.effect === 'karama');
return g;
}
function choose(g: Game, target = 'e') {
return applyAction(g, 'f', {
type: 'chooseBattle',
territory: 'red_chasm',
target,
});
}
function passWindow(state: Game) {
let g = state;
const kind = g.response!.kind;
for (const p of g.players) {
if (g.response?.kind !== kind) break;
g = applyAction(g, p.id, { type: 'passResponse' });
}
return g;
}
function allow(state: Game) {
let g = state;
while (g.response) g = passWindow(g);
return g;
}
function cancel(g: Game) {
return applyAction(g, 'g', {
type: 'card',
card: g.players[2].hand[0].id,
mode: 'cancel',
});
}
function plan(g: Game, dial: number, support = 0) {
return applyAction(g, 'f', {
type: 'battlePlan',
dial,
support,
leader: 'fremen-0',
});
}
function resolve(state: Game, traitor = false) {
let g = applyAction(state, 'e', {
type: 'battlePlan',
dial: 0,
leader: 'emperor-4',
});
g = applyAction(g, 'f', { type: 'traitorCall', call: traitor });
return applyAction(g, 'e', { type: 'traitorCall', call: false });
}
void test('Fedaykin and free spice support require separate Karama cards; Sardaukar is already ordinary against Fremen', () => {
let g = choose(fixture());
assert.equal(g.response?.kind, 'eliteStrength');
assert.equal(g.response?.owner, 'f');
const before = structuredClone(g.players[0].elites);
g = cancel(g);
assert.equal(g.response?.kind, 'fremenSupport');
assert.deepEqual(g.battle!.eliteBlocked, ['f']);
assert.equal(g.battle!.fremenSupportBlocked, undefined);
g = cancel(g);
assert.equal(g.response, null);
assert.equal(g.players[2].hand.length, 0);
assert.equal(g.battle!.fremenSupportBlocked, true);
assert.deepEqual(g.players[0].elites, before);
assert.equal(viewGame(g, 'e').battle!.fremenSupportBlocked, true);
assert.deepEqual(viewGame(g, 'g').battle!.eliteBlocked, ['f']);
assert.throws(() => plan(g, 4, 3), /legal force commitment/);
assert.equal(plan(g, 3, 3).battle!.plans.f.dial, 3);
});
void test('canceling elite strength alone retains Fremen free support and elite token identity in casualties', () => {
let g = allow(cancel(choose(fixture())));
assert.throws(() => plan(g, 4), /legal force commitment/);
g = resolve(plan(g, 3));
assert.equal(g.players[0].spice, 10);
if (g.decision?.kind === 'battleLosses')
g = applyAction(g, 'f', { type: 'decision', choice: 0 });
assert.equal(g.players[0].tanks, 3);
assert.equal(g.players[0].elites!.tanks, 1);
assert.equal(g.players[0].elites!.reserves, 2);
});
void test('canceling free support alone keeps Fedaykin doubled, supports half-strength dials and reserves payment privately', () => {
let g = cancel(passWindow(choose(fixture())));
assert.deepEqual(g.battle!.eliteBlocked ?? [], []);
assert.throws(() => plan(g, 4), /legal force commitment/);
assert.equal(plan(g, 1.5).battle!.plans.f.support, 0);
g = plan(g, 4, 3);
assert.equal(viewGame(g, 'e').battle!.plans.f, undefined);
assert.equal(viewGame(g, 'f').battle!.plans.f.support, 3);
assert.equal(g.players[0].spice, 10);
g = resolve(g);
assert.equal(g.players[0].spice, 7);
});
void test('a traitor winner avoids spice payment even when free support was canceled', () => {
let g = fixture();
g.players[0].traitors = ['emperor-4'];
g = cancel(passWindow(choose(g)));
g = resolve(plan(g, 4, 3), true);
assert.equal(g.players[0].spice, 12); // traitor leader bounty
assert.equal(g.players[0].tanks, 0);
assert.equal(g.players[0].elites!.forces['red_chasm:7'], 1);
});
void test('combat power responses reject own-power cancellation and premature plans atomically', () => {
let g = choose(fixture());
g.players[0].hand = [g.players[2].hand.pop()!];
const before = structuredClone(g);
assert.throws(
() =>
applyAction(g, 'f', {
type: 'card',
card: g.players[0].hand[0].id,
mode: 'cancel',
}),
/another faction/,
);
assert.throws(() => plan(g, 0), /response window/);
assert.deepEqual(g, before);
g = allow(g);
assert.equal(plan(g, 4).battle!.plans.f.dial, 4);
const settled = structuredClone(g);
assert.throws(() => plan(g, 4, 1), /legal force commitment/);
assert.deepEqual(g, settled);
});
void test('cancellation expires after its battle and new combat receives fresh power windows', () => {
let g = cancel(cancel(choose(fixture())));
g = resolve(plan(g, 0));
while (g.decision) {
const d = g.decision;
g = applyAction(
g,
d.player,
d.kind === 'battleLosses'
? { type: 'decision', choice: 0 }
: { type: 'decision', discard: [] },
);
}
g.phase = 6;
g.active = 'f';
g.players[1].forces = { 'red_chasm:7': 3 };
g.players[1].tanks = 0;
g.players[1].elites = {
reserves: 4,
tanks: 0,
forces: { 'red_chasm:7': 1 },
revived: 0,
};
g.players[1].hand = [baseDeck().find((c) => c.effect === 'karama')!];
g = choose(g);
assert.equal(g.response?.kind, 'eliteStrength');
assert.deepEqual(g.battle!.eliteBlocked ?? [], []);
assert.equal(g.battle!.fremenSupportBlocked ?? false, false);
assert.equal(plan(allow(g), 4).battle!.plans.f.dial, 4);
});
void test('Emperor elite cancellation reduces Sardaukar against other factions without removing elite tokens', () => {
let g = fixture();
g.players[0].forces = {};
g.players[2].forces = { 'red_chasm:7': 3 };
g.active = 'e';
g = applyAction(g, 'e', {
type: 'chooseBattle',
territory: 'red_chasm',
target: 'g',
});
assert.equal(g.response?.kind, 'eliteStrength');
assert.equal(g.response?.owner, 'e');
g = cancel(g);
const action = {
type: 'battlePlan',
leader: 'emperor-0',
dial: 4,
support: 3,
};
assert.throws(() => applyAction(g, 'e', action), /legal force commitment/);
assert.equal(
applyAction(g, 'e', { ...action, dial: 3 }).battle!.plans.e.dial,
3,
);
assert.equal(g.players[1].elites!.forces['red_chasm:7'], 1);
});
void test('no elite response is offered without elite forces or in basic combat', () => {
const g = fixture();
g.players[0].elites!.forces = {};
g.players[0].elites!.reserves = 3;
assert.equal(choose(g).response?.kind, 'fremenSupport');
g.advanced = false;
assert.equal(choose(g).response, null);
});