Files
Dune/tests/normal-revival-options.test.ts
2026-09-08 07:37:52 +10:00

158 lines
5.4 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, createGame, newPlayer, viewGame } from '../game/engine';
import { leaders } from '../game/cards';
import { leaderRevivalOptions, newRevivalRules } from '../game/revival';
import { createDukeVidal } from '../game/duke-vidal';
function fixture() {
const g = createGame('REVOPTIONS', newPlayer('a', 'Atreides', 'atreides'));
g.players.push(newPlayer('h', 'Harkonnen', 'harkonnen'));
g.status = 'playing';
g.phase = 4;
g.revivalRules = newRevivalRules();
g.order = ['a', 'h'];
for (const p of g.players) {
p.spice = 20;
p.leaders = leaders(p.faction);
}
for (const l of g.players[0].leaders) {
l.dead = true;
l.deaths = 1;
}
return g;
}
void test('normal revival projection opens without mutating state and exposes only viewer options', () => {
const g = fixture();
const before = structuredClone(g);
assert.equal(viewGame(g, 'a').revival.leaders.length, 5);
assert.deepEqual(viewGame(g, 'h').revival.leaders, []);
assert.deepEqual(g, before);
const leader = viewGame(g, 'a').revival.leaders[0];
const next = applyAction(g, 'a', { type: 'reviveLeader', leader: leader.id });
assert.equal(next.players[0].spice, 20 - leader.cost);
assert.equal(next.players[0].revivalCycle, 1);
assert.deepEqual(viewGame(next, 'a').revival.leaders, []);
});
void test('opened cycle continues in later phases with a living returned leader', () => {
let g = fixture();
g = applyAction(g, 'a', {
type: 'reviveLeader',
leader: g.players[0].leaders[0].id,
});
g.turn += 1;
g.players[0].leaderRevived = false;
const options = viewGame(JSON.parse(JSON.stringify(g)), 'a').revival.leaders;
assert.equal(options.length, 4);
for (const option of options) {
const next = applyAction(g, 'a', {
type: 'reviveLeader',
leader: option.id,
});
assert.equal(
next.players[0].leaders.find((l) => l.id === option.id)?.dead,
false,
);
}
});
void test('face-down second deaths, captured discs and foreign gholas are not ordinary targets', () => {
const g = fixture();
const p = g.players[0];
p.revivalCycle = 1;
p.leaders[0].dead = false;
p.leaders[1].deaths = 2;
p.leaders[2].capturedBy = 'h';
p.leaders[3].gholaBy = 'h';
const options = viewGame(g, 'a').revival.leaders;
assert.deepEqual(
options.map((l) => l.id),
[p.leaders[4].id],
);
for (const l of p.leaders.slice(1, 4))
assert.throws(() =>
applyAction(g, 'a', { type: 'reviveLeader', leader: l.id }),
);
});
void test('capture absence can open the ordinary cycle without exposing the captor identity', () => {
const g = fixture();
g.players[0].leaders[0].dead = false;
g.players[0].leaders[0].capturedBy = 'h';
const options = viewGame(g, 'a').revival.leaders;
assert.equal(options.length, 4);
assert.ok(options.every((o) => !('capturedBy' in o)));
assert.doesNotThrow(() =>
applyAction(g, 'a', { type: 'reviveLeader', leader: options[0].id }),
);
});
void test('quotes show affordability and current ally discount without changing revival permission', () => {
const g = fixture();
g.players.push(newPlayer('t', 'Tleilaxu', 'tleilaxu'));
g.players[0].ally = 't';
g.players[2].ally = 'a';
g.revivalRules!.allyDiscount = 'a';
g.players[0].spice = 0;
for (const o of viewGame(g, 'a').revival.leaders) {
assert.equal(o.cost, Math.ceil(o.normalCost / 2));
assert.equal(o.affordable, false);
assert.throws(() =>
applyAction(g, 'a', { type: 'reviveLeader', leader: o.id }),
);
}
g.revivalRules!.discountBlocked = true;
assert.ok(
viewGame(g, 'a').revival.leaders.every((o) => o.cost === o.normalCost),
);
});
void test('Tleilaxu self early revival honors canceled targets while ordinary eligible targets remain usable', () => {
const g = fixture();
const p = g.players[0];
p.faction = 'tleilaxu';
p.leaders = leaders('tleilaxu');
p.leaders[0].dead = true;
p.leaders[0].deaths = 1;
assert.equal(leaderRevivalOptions(g, p).leaders[0].early, true);
p.leaderRevived = true;
assert.equal(leaderRevivalOptions(g, p).leaders.length, 1);
g.revivalRules!.earlyBlocked.push(`${p.id}:${p.leaders[0].id}`);
assert.deepEqual(leaderRevivalOptions(g, p).leaders, []);
p.leaderRevived = false;
for (const l of p.leaders) {
l.dead = true;
l.deaths = 1;
}
assert.equal(leaderRevivalOptions(g, p).leaders.length, 5);
assert.ok(leaderRevivalOptions(g, p).leaders.every((o) => !o.early));
});
void test('prevention and phase changes remove options including Kwisatz', () => {
const g = fixture();
g.advanced = true;
g.players[0].kwisatz = { dead: true, revivalCycle: 1 };
assert.equal(viewGame(g, 'a').revival.kwisatz?.cost, 2);
g.revivalPrevention = { turn: g.turn, player: 'a' };
assert.deepEqual(viewGame(g, 'a').revival.leaders, []);
assert.equal(viewGame(g, 'a').revival.kwisatz, null);
delete g.revivalPrevention;
g.phase = 5;
assert.deepEqual(viewGame(g, 'a').revival.leaders, []);
assert.equal(viewGame(g, 'a').revival.kwisatz, null);
});
void test('shared Duke does not enter native revival projection or inventory', () => {
const g = fixture();
g.dukeVidal = createDukeVidal();
g.dukeVidal.leader.dead = true;
g.dukeVidal.leader.deaths = 1;
assert.equal(viewGame(g, 'a').revival.leaders.length, 5);
assert.ok(
viewGame(g, 'a').revival.leaders.every((l) => l.id !== 'duke-vidal'),
);
assert.equal(g.players[0].leaders.length, 5);
});