Initial commit

This commit is contained in:
Quintine committed 2026-09-08 07:37:52 +10:00
commit 8c709fb52f
771 files changed
+222983

No files matched your search

+506
View File
@@ -0,0 +1,506 @@
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,
battleLeaderStrength,
type Card,
} from '../game/cards';
import { traitorDeck, CHEAP_HERO_TRAITOR } from '../game/traitors';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { createTechTokens } from '../game/tech-tokens';
/** Assign fixture cards by transferring their exact physical IDs from the deck. */
function setHand(g: Game, index: number, selected: Card[]) {
const owner = g.players[index];
const ids = new Set(selected.map((c) => c.id));
assert.equal(ids.size, selected.length);
const held = new Map(owner.hand.map((c) => [c.id, c]));
for (const previous of owner.hand)
if (!ids.has(previous.id)) g.deck.push(previous);
owner.hand = selected.map((wanted) => {
if (held.has(wanted.id)) return held.get(wanted.id)!;
assert.ok(
!g.players.some(
(p, i) => i !== index && p.hand.some((c) => c.id === wanted.id),
),
`Another player owns ${wanted.id}`,
);
const at = g.deck.findIndex((c) => c.id === wanted.id);
assert.ok(at >= 0, `Fixture deck must contain ${wanted.id}`);
return g.deck.splice(at, 1)[0];
});
}
function fixture(setup = false) {
let g = createGame('TLEILAXU2', newPlayer('a', 'Atreides', 'atreides'));
joinGame(g, newPlayer('e', 'Emperor', 'emperor'));
joinGame(g, newPlayer('t', 'Tleilaxu', 'guild'));
g.players.forEach((p) => (p.ready = true));
g = applyAction(g, 'a', { type: 'start' });
const t = g.players[2];
t.faction = 'tleilaxu';
t.leaders = leaders('tleilaxu');
t.traitorChoices = [];
t.traitors = [];
for (const p of g.players)
p.traitorChoices = p.traitorChoices.map((id) =>
id.replace('guild-', 'tleilaxu-'),
);
if (setup) return g;
for (const p of g.players)
if (p.traitorChoices.length)
g = applyAction(g, p.id, {
type: 'traitor',
leader: p.traitorChoices[0],
});
g.phase = 6;
g.active = 'a';
g.order = ['a', 'e', 't'];
g.storm = 18;
// Return dealt cards before replacing setup hands for this battle fixture.
g.deck.push(...g.players.flatMap((p) => p.hand));
for (const p of g.players) {
p.hand = [];
p.traitors = [];
p.spice = 20;
p.forces = p.id === 't' ? { 'polar_sink:0': 2 } : { 'arrakeen:10': 5 };
p.reserves = p.id === 't' ? 18 : 15;
}
g.players[2].faceDancers = [
{ leader: 'atreides-0', revealed: false },
{ leader: 'emperor-1', revealed: false },
{ leader: CHEAP_HERO_TRAITOR, revealed: false },
];
g.traitorReserve = traitorDeck(g.players, true).filter(
(id) => !g.players[2].faceDancers!.some((c) => c.leader === id),
);
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, attacker = 'a', defender = 'e') {
let g = allow(
applyAction(state, attacker, {
type: 'chooseBattle',
territory: 'arrakeen',
target: defender,
}),
);
while (g.battle?.preparation)
g = applyAction(g, g.battle.preparation.owner, {
type: 'declineBattlePower',
});
if (g.decision?.kind === 'fullPlanOffer')
g = applyAction(g, g.decision.player, { type: 'decision', decline: true });
return g;
}
function battle(state: Game, a: object = {}, e: object = {}) {
let g = prepare(state);
g = applyAction(g, 'a', {
type: 'battlePlan',
dial: 1,
leader: 'atreides-0',
...a,
});
g = applyAction(g, 'e', {
type: 'battlePlan',
dial: 0,
leader: 'emperor-1',
...e,
});
g = applyAction(g, 'a', { type: 'traitorCall', call: false });
return applyAction(g, 'e', { type: 'traitorCall', call: false });
}
const reveal = (
g: Game,
sources: Record<string, number> = { reserves: 2, 'polar_sink:0': 2 },
) =>
applyAction(g, 't', { type: 'decision', reveal: true, sources, sector: 10 });
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,
p.id,
);
}
void test('Tleilaxu draw three private Face Dancers after ordinary selections, excluding held traitors', () => {
let g = fixture(true);
g.expansions = ['ix'];
g = applyAction(g, 'a', {
type: 'traitor',
leader: g.players[0].traitorChoices[0],
});
assert.equal(g.players[2].faceDancers, undefined);
g = applyAction(g, 'e', {
type: 'traitor',
leader: g.players[1].traitorChoices[0],
});
assert.equal(g.status, 'playing');
assert.deepEqual(g.players[2].traitors, []);
const all = [
...g.players.flatMap((p) => p.traitors),
...g.players[2].faceDancers!.map((c) => c.leader),
...g.traitorReserve!,
];
assert.equal(all.length, 16);
assert.equal(new Set(all).size, 16);
assert.equal(g.players[2].faceDancers!.length, 3);
assert.equal(viewGame(g, 'a').players[2].faceDancers, undefined);
assert.equal('traitorReserve' in viewGame(g, 't'), false);
});
void test('Face Dancer replaces surviving winner forces from reserves and board without changing the battle rewards', () => {
let g = battle(fixture());
assert.equal(g.decision?.kind, 'faceDance');
assert.equal(g.players[0].tanks, 1);
assert.equal(g.players[1].tanks, 5);
const snapshot = structuredClone(g);
g = reveal(JSON.parse(JSON.stringify(g)));
assert.equal(g.players[0].reserves, 19);
assert.equal(g.players[0].tanks, 1);
assert.equal(g.players[2].forces['arrakeen:10'], 4);
assert.equal(g.players[2].forces['polar_sink:0'], undefined);
assert.equal(g.players[0].leaders[0].dead, true);
assert.equal(g.players[0].spice, snapshot.players[0].spice);
assert.deepEqual(viewGame(g, 'e').players[2].revealedFaceDancers, [
'atreides-0',
]);
assert.equal(g.response, null);
conserved(g);
});
void test('winner chooses cards and receives a tech token before Face Dancer replacement', () => {
const before = fixture();
const shield = baseDeck().find((c) => c.kind === 'shield')!;
setHand(before, 0, [shield]);
before.techTokens = createTechTokens();
before.techTokens.heighliners.owner = 'e';
let g = battle(before, { defense: shield.id });
assert.equal(g.decision?.kind, 'battleCards');
g = applyAction(g, 'a', { type: 'decision', discard: [] });
assert.equal(g.decision?.kind, 'faceDance');
assert.equal(g.techTokens?.heighliners.owner, 'a');
g = reveal(g);
assert.ok(g.players[0].hand.some((c) => c.id === shield.id));
assert.equal(g.techTokens?.heighliners.owner, 'a');
});
void test('wrong owner, wrong matching card, excessive replacements and alien sources fail immutably', () => {
const g = battle(fixture());
const snapshot = structuredClone(g);
assert.throws(
() => applyAction(g, 'a', { type: 'decision', reveal: false }),
/pending decision/,
);
assert.throws(() => reveal(g, { reserves: 5 }), /more forces/);
assert.throws(() => reveal(g, { 'arrakeen:10': 1 }), /own reserves/);
assert.throws(() => reveal(g, { reserves: -1 }), /integer/);
assert.deepEqual(g, snapshot);
const missing = structuredClone(g);
missing.players[2].faceDancers = [];
assert.throws(() => reveal(missing), /unrevealed Face Dancer/);
assert.equal(viewGame(missing, 'a').decision?.kind, 'faceDance');
});
void test('Face Dancer can be declined without disclosing private cards', () => {
const before = battle(fixture());
const g = applyAction(before, 't', { type: 'decision', reveal: false });
assert.equal(g.players[0].leaders[0].dead, false);
assert.equal(g.players[0].forces['arrakeen:10'], 4);
assert.deepEqual(g.players[2].faceDancers, before.players[2].faceDancers);
assert.deepEqual(viewGame(g, 'a').players[2].revealedFaceDancers, []);
});
void test('winning Cheap Hero can be a Face Dancer even though the hero treachery card has been discarded', () => {
const before = fixture();
const heroes = baseDeck().filter((c) => c.kind === 'hero');
setHand(before, 0, [heroes[0]]);
setHand(before, 1, [heroes[1]]);
let g = battle(before, { leader: heroes[0].id }, { leader: heroes[1].id });
assert.equal(g.decision?.kind, 'faceDance');
assert.ok(g.discard.some((c) => c.id === heroes[0].id));
g = reveal(g);
assert.ok(g.players[0].leaders.every((l) => !l.dead));
assert.equal(g.players[2].forces['arrakeen:10'], 4);
});
void test('KH protects against traitors but does not stop a Face Dancer', () => {
const before = fixture();
before.advanced = true;
before.players[0].battleLosses = 7;
let g = battle(before, { kwisatz: true, dial: 0 });
while (g.decision?.kind === 'battleLosses')
g = applyAction(g, 'a', { type: 'decision', choice: 0 });
assert.equal(g.decision?.kind, 'faceDance');
g = reveal(g);
assert.equal(g.players[0].leaders[0].dead, true);
assert.equal(g.players[0].kwisatz?.dead, false);
});
void test('a winning leader already killed in battle is not killed a second time by its Face Dancer', () => {
const before = fixture();
const poison = baseDeck().find((c) => c.kind === 'poison')!;
setHand(before, 1, [poison]);
let g = battle(
before,
{ dial: 3 },
{ leader: 'emperor-4', weapon: poison.id },
);
assert.equal(g.players[0].leaders[0].dead, true);
const deaths = g.players[0].leaders[0].deaths;
g = reveal(g, { reserves: 2 });
assert.equal(g.players[0].leaders[0].deaths, deaths);
assert.equal(g.players[0].spice, 25);
});
void test('the third reveal recycles all three Face Dancers into the private deck', () => {
const before = fixture();
before.players[2].faceDancers!.slice(1).forEach((c) => (c.revealed = true));
const beforeCards = [
...before.traitorReserve!,
...before.players[2].faceDancers!.map((c) => c.leader),
].sort();
const g = reveal(battle(before));
assert.ok(g.players[2].faceDancers!.every((c) => !c.revealed));
assert.equal(g.players[2].faceDancers!.length, 3);
assert.deepEqual(
[
...g.traitorReserve!,
...g.players[2].faceDancers!.map((c) => c.leader),
].sort(),
beforeCards,
);
});
void test('Mentat replacement is once per turn, private, cancelable and conserves the deck', () => {
let g = fixture();
g.phase = 8;
const before = [
...g.traitorReserve!,
...g.players[2].faceDancers!.map((c) => c.leader),
].sort();
g = applyAction(g, 't', { type: 'replaceFaceDancer', leader: 'atreides-0' });
assert.equal(viewGame(g, 'a').response?.intent, undefined);
g = allow(JSON.parse(JSON.stringify(g)));
assert.equal(g.players[2].faceDancerReplacedTurn, g.turn);
assert.deepEqual(
[
...g.traitorReserve!,
...g.players[2].faceDancers!.map((c) => c.leader),
].sort(),
before,
);
assert.throws(
() =>
applyAction(g, 't', {
type: 'replaceFaceDancer',
leader: g.players[2].faceDancers![0].leader,
}),
/one Face Dancer/,
);
let cancel = fixture();
cancel.phase = 8;
const karama = baseDeck().find((c) => c.effect === 'karama')!;
setHand(cancel, 0, [karama]);
const cards = structuredClone(cancel.players[2].faceDancers);
cancel = applyAction(cancel, 't', {
type: 'replaceFaceDancer',
leader: 'atreides-0',
});
cancel = applyAction(cancel, 'a', {
type: 'card',
mode: 'cancel',
card: karama.id,
});
assert.deepEqual(cancel.players[2].faceDancers, cards);
assert.equal(cancel.players[2].faceDancerReplacedTurn, cancel.turn);
});
void test('Zoal copies the opposite leader disc, has zero against a hero, and retains a revival value of three', () => {
const zoal = leaders('tleilaxu')[0];
assert.equal(zoal.strength, 3);
assert.equal(battleLeaderStrength(zoal, leaders('fremen')[0]), 7);
assert.equal(battleLeaderStrength(zoal, undefined), 0);
assert.deepEqual(
leaders('ixians').map((l) => l.strength),
[4, 5, 5, 2, 1],
);
});
void test('Zoal battle totals and killed-leader spice use the copied strength rather than his revival cost', () => {
const before = fixture();
before.players[2].forces = { 'arrakeen:10': 5 };
before.players[2].reserves = 15;
before.players[0].forces = {};
before.players[0].reserves = 20;
before.active = 't';
before.order = ['t', 'e', 'a'];
const poison = baseDeck().find((c) => c.kind === 'poison')!;
setHand(before, 1, [poison]);
let g = prepare(before, 't', 'e');
g = applyAction(g, 't', {
type: 'battlePlan',
leader: 'tleilaxu-0',
dial: 5,
});
g = applyAction(g, 'e', {
type: 'battlePlan',
leader: 'emperor-0',
dial: 0,
weapon: poison.id,
});
g = applyAction(g, 't', { type: 'traitorCall', call: false });
g = applyAction(g, 'e', { type: 'traitorCall', call: false });
assert.equal(g.players[2].leaders[0].dead, true);
assert.equal(g.players[1].spice, 26);
});
void test('all AI difficulties handle Face Dancer decisions using only their private matching cards', () => {
for (const difficulty of DIFFICULTIES) {
const g = battle(fixture());
g.players[2].bot = difficulty;
const action = botActions(viewGame(g, 't'))[0];
assert.equal(action.reveal, true);
assert.doesNotThrow(() => applyAction(g, 't', action));
g.players[2].faceDancers = [];
assert.deepEqual(botActions(viewGame(g, 't')), [
{ type: 'decision', reveal: false },
]);
}
});
void test('expansion factions cannot bypass the unfinished-expansion start gate with an empty expansion selection', () => {
const g = createGame('GATEDTEST2', newPlayer('t', 'Tleilaxu', 'tleilaxu'));
joinGame(g, newPlayer('a', 'Atreides', 'atreides'));
g.players.forEach((p) => (p.ready = true));
assert.throws(
() => applyAction(g, 't', { type: 'start' }),
/Expansion factions/,
);
});
void test('Face Dancer returns Sardaukar to reserves and leaves bystanding Bene Gesserit advisors in place', () => {
const before = fixture();
before.advanced = true;
before.players[1].elites = {
reserves: 2,
tanks: 0,
forces: { 'arrakeen:10': 3 },
revived: 0,
};
before.players[2].faceDancers![0].leader = 'emperor-0';
const bg = newPlayer('b', 'Bene Gesserit', 'beneGesserit');
bg.forces = { 'arrakeen:10': 1 };
bg.reserves = 19;
bg.advisors = { arrakeen: { lockedTurn: before.turn } };
before.players.push(bg);
before.order.push('b');
let g = battle(before, { dial: 0 }, { leader: 'emperor-0' });
if (g.decision?.kind === 'battleLosses')
g = applyAction(g, 'e', { type: 'decision', choice: 0 });
assert.equal(g.decision?.kind, 'faceDance');
g = reveal(g, { reserves: 5 });
assert.equal(g.players[1].reserves, 20);
assert.equal(g.players[1].elites?.reserves, 5);
assert.deepEqual(g.players[1].elites?.forces, {});
assert.equal(g.players[3].forces['arrakeen:10'], 1);
assert.ok(g.players[3].advisors?.arrakeen);
conserved(g);
});
void test('Harkonnen capture resolves first, then loss of its last native leader releases the captive', () => {
const before = fixture();
before.advanced = true;
before.players[0].faction = 'harkonnen';
before.players[0].leaders = leaders('harkonnen');
before.players[0].leaders.slice(1).forEach((l) => {
l.dead = true;
l.deaths = 1;
});
before.players[2].faceDancers![0].leader = 'harkonnen-0';
let g = battle(before, { leader: 'harkonnen-0', dial: 0 });
if (g.decision?.kind === 'battleLosses')
g = applyAction(g, 'a', { type: 'decision', choice: 0 });
assert.equal(g.decision?.kind, 'captureOffer');
g = allow(applyAction(g, 'a', { type: 'decision', accept: true }));
assert.equal(g.decision?.kind, 'capturedLeader');
const captive =
g.decision?.kind === 'capturedLeader' ? g.decision.leader : '';
g = applyAction(g, 'a', { type: 'decision', mode: 'keep' });
assert.equal(g.decision?.kind, 'faceDance');
assert.equal(
g.players[1].leaders.find((l) => l.id === captive)?.capturedBy,
'a',
);
g = reveal(g);
assert.equal(
g.players[1].leaders.find((l) => l.id === captive)?.capturedBy,
undefined,
);
});
void test('a successful traitor call against Zoal awards the opposing leader’s strength', () => {
const before = fixture();
before.players[2].forces = { 'arrakeen:10': 5 };
before.players[2].reserves = 15;
before.players[1].forces = {};
before.players[1].reserves = 20;
before.players[0].traitors = ['tleilaxu-0'];
let g = prepare(before, 'a', 't');
g = applyAction(g, 'a', {
type: 'battlePlan',
leader: 'atreides-0',
dial: 0,
});
g = applyAction(g, 't', {
type: 'battlePlan',
leader: 'tleilaxu-0',
dial: 0,
});
g = applyAction(g, 'a', { type: 'traitorCall', call: true });
g = applyAction(g, 't', { type: 'traitorCall', call: false });
assert.equal(g.players[0].spice, 25);
assert.equal(g.players[2].leaders[0].dead, true);
});
void test('ordinary Karama cannot cancel a Face Dancer reveal or consume the selected card', () => {
const before = fixture();
const karama = baseDeck().find((c) => c.effect === 'karama')!;
setHand(before, 0, [karama]);
const g = battle(before);
assert.throws(
() =>
applyAction(g, 'a', { type: 'card', mode: 'cancel', card: karama.id }),
/pending decision/,
);
const after = reveal(g);
assert.ok(after.players[0].hand.some((c) => c.id === karama.id));
});
void test('setup completes without a human selection when only Harkonnen and Tleilaxu are present', () => {
const g = fixture(true);
g.players = g.players.filter((p) => p.id !== 'e');
g.players[0].faction = 'harkonnen';
g.players[0].leaders = leaders('harkonnen');
g.players[0].traitorChoices = [];
g.players[0].traitors = [
'harkonnen-0',
'harkonnen-1',
'harkonnen-2',
'harkonnen-3',
];
g.order = ['a', 't'];
g.expansions = ['ix'];
const next = applyAction(g, 't', { type: 'advanceBots' });
assert.equal(next.status, 'playing');
assert.equal(next.players[1].faceDancers?.length, 3);
assert.ok(next.phaseOpening);
assert.equal(
new Set([
...next.players[0].traitors,
...next.traitorReserve!,
...next.players[1].faceDancers!.map((c) => c.leader),
]).size,
11,
);
});