238 lines
7.5 KiB
TypeScript
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);
|
|
});
|