Files
Dune/tests/nexus-tleilaxu-revival.test.ts

491 lines
18 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { createGame, newPlayer, type Game, type Player } from '../game/engine';
import { createAuditorLeader, leaders } from '../game/cards';
import type { FactionId } from '../game/catalog';
import { createNexusCards } from '../game/nexus-cards';
import { forceRevivalRemaining, leaderRevivalOptions, newRevivalRules } from '../game/revival';
import { createDukeVidal, DUKE_VIDAL_ID } from '../game/duke-vidal';
import {
NEXUS_TLEILAXU_FORCE_GROUP_CAP,
NEXUS_TLEILAXU_POLICY,
TleilaxuSecretAllyError,
quoteTleilaxuSecretAlly,
tleilaxuSecretAllyBlock,
tleilaxuSecretAllyEvent,
tleilaxuSecretAllyForceBounds,
tleilaxuSecretAllyOptions,
} from '../game/nexus-tleilaxu-revival';
/** A Treachery card id, i.e. what a battle plan uses for a Cheap Hero. */
const CHEAP_HERO = 'treachery-17';
function holdNexusCard(g: Game, holder: string) {
const cards = createNexusCards(
g.players.map((seat) => ({
id: seat.id,
faction: seat.faction,
ally: seat.ally,
})),
() => 0.5,
);
cards.deck = cards.deck.filter((card) => card !== 'tleilaxu');
cards.hands[holder] = 'tleilaxu';
g.nexusCards = { cards, phase: null };
}
function fixture(advanced = true, faction: FactionId = 'atreides') {
const g = createGame('NEXUSREVIVAL', newPlayer('a', 'Holder', faction), advanced);
g.players.push(newPlayer('h', 'Rival', 'harkonnen'));
Object.assign(g, {
status: 'playing',
phase: 4,
turn: 3,
order: ['a', 'h'],
revivalRules: newRevivalRules(),
});
for (const p of g.players) {
p.spice = 20;
p.tanks = 10;
p.elites = { reserves: 0, tanks: 0, forces: {}, revived: 0 };
}
holdNexusCard(g, 'a');
return g;
}
function kill(p: Player, indexes: number[], deaths = 1) {
for (const index of indexes) {
p.leaders[index].dead = true;
p.leaders[index].deaths = deaths;
}
}
void test('actual held card, unallied non-Tleilaxu holder, quiet Revival and an owner-only event bind the projection', () => {
const g = fixture();
kill(g.players[0], [0]);
const before = structuredClone(g);
const offer = tleilaxuSecretAllyOptions(g, g.players[0]);
assert.equal(offer.blocked, null);
assert.equal(tleilaxuSecretAllyBlock(g, g.players[0]), null);
assert.equal(offer.event, tleilaxuSecretAllyEvent(g, g.players[0]));
assert.equal(
offer.event,
JSON.stringify(['nexusTleilaxuSecretAlly', 3, 4, 'a']),
);
assert.equal(offer.policy, NEXUS_TLEILAXU_POLICY);
assert.deepEqual(offer.leaders, [
{
id: g.players[0].leaders[0].id,
name: g.players[0].leaders[0].name,
kind: 'leader',
early: true,
},
]);
assert.deepEqual(g, before);
g.nexusCards!.cards!.hands.a = null;
assert.match(tleilaxuSecretAllyBlock(g, g.players[0])!, /holder of the Tleilaxu Nexus card/);
g.nexusCards!.cards!.hands.a = 'tleilaxu';
g.players[0].ally = 'h';
g.players[1].ally = 'a';
assert.match(tleilaxuSecretAllyBlock(g, g.players[0])!, /allied player/);
g.players[0].ally = null;
g.players[1].ally = null;
g.players.push(newPlayer('t', 'Native', 'tleilaxu'));
assert.match(tleilaxuSecretAllyBlock(g, g.players[0])!, /Tleilaxu absent/);
g.players.pop();
g.phase = 5;
assert.match(tleilaxuSecretAllyBlock(g, g.players[0])!, /during Revival/);
assert.throws(
() =>
quoteTleilaxuSecretAlly(g, g.players[0], {
leader: g.players[0].leaders[0].id,
amount: 0,
elite: 0,
}),
(error: unknown) =>
error instanceof TleilaxuSecretAllyError && error.reason === 'timing',
);
g.phase = 4;
g.status = 'lobby';
assert.match(tleilaxuSecretAllyBlock(g, g.players[0])!, /started game/);
g.status = 'playing';
g.pendingCapture = { player: 'a', loser: 'h', territory: 'arrakeen' };
assert.match(tleilaxuSecretAllyBlock(g, g.players[0])!, /Finish the current interaction/);
g.pendingCapture = null;
assert.equal(tleilaxuSecretAllyBlock(g, g.players[0]), null);
});
void test('the roster waiver belongs to the card alone and never opens the ordinary cycle', () => {
const g = fixture();
const p = g.players[0];
kill(p, [0]);
// Ordinary rules still refuse: the cohort has never been opened (cycle 0).
assert.deepEqual(leaderRevivalOptions(g, p).leaders, []);
assert.equal(leaderRevivalOptions(g, p).cycle, 0);
const offer = tleilaxuSecretAllyOptions(g, p);
assert.deepEqual(
offer.leaders.map((leader) => [leader.id, leader.kind, leader.early]),
[[p.leaders[0].id, 'leader', true]],
);
// The synthetic cycle is a local projection only.
assert.equal(p.revivalCycle, 0);
assert.deepEqual(leaderRevivalOptions(g, p).leaders, []);
assert.deepEqual(g.revivalRules!.earlyBlocked, []);
// A second death is not returned early while the cohort has never been opened,
// and the same disc is an ordinary candidate once that cohort level is open.
const second = fixture();
const seat = second.players[0];
kill(seat, [0], 2);
const withheld = tleilaxuSecretAllyOptions(second, seat);
assert.deepEqual(withheld.leaders, []);
assert.match(withheld.blocked!, /one own dead leader/);
seat.revivalCycle = 2;
assert.deepEqual(
tleilaxuSecretAllyOptions(second, seat).leaders.map((leader) => [leader.id, leader.early]),
[[seat.leaders[0].id, false]],
);
});
void test('the Tleilaxu waiver reaches an unopened saved Ecaz cohort without reopening a previously returned disc', () => {
const g = fixture(false, 'ecaz'), p = g.players[0];
g.dukeVidal = createDukeVidal();
kill(p, [0], 2);
kill(p, [1]);
p.ecazRevivalCohort = { cycle: 0, returned: [p.leaders[0].id] };
const before = structuredClone(g);
assert.deepEqual(leaderRevivalOptions(g, p).leaders, []);
assert.deepEqual(tleilaxuSecretAllyOptions(g, p).leaders.map(l => l.id), [p.leaders[1].id]);
const quote = quoteTleilaxuSecretAlly(g, p, { leader: p.leaders[1].id, amount: 0, elite: 0 });
assert.equal(quote.kind, 'leader');
assert.equal(quote.cost, 0);
assert.throws(() => quoteTleilaxuSecretAlly(g, p, { leader: p.leaders[0].id, amount: 0, elite: 0 }));
assert.deepEqual(leaderRevivalOptions(g, p).leaders, []);
assert.deepEqual(g, before);
});
void test('an opened ordinary cohort keeps canonical eligibility and is never marked early', () => {
const g = fixture();
const p = g.players[0];
for (const leader of p.leaders) {
leader.dead = true;
leader.deaths = 1;
}
const offer = tleilaxuSecretAllyOptions(g, p);
assert.deepEqual(
offer.leaders.map((leader) => leader.id),
p.leaders.map((leader) => leader.id),
);
assert.ok(offer.leaders.every((leader) => leader.kind === 'leader' && !leader.early));
});
void test('one own dead leader is mandatory: zero forces is legal, a force-only return is not, and the used slot closes it', () => {
const g = fixture();
const p = g.players[0];
const offer = tleilaxuSecretAllyOptions(g, p);
assert.deepEqual(offer.leaders, []);
assert.match(offer.blocked!, /one own dead leader/);
assert.ok(offer.maxForces > 0);
assert.throws(
() =>
quoteTleilaxuSecretAlly(g, p, {
leader: p.leaders[0].id,
amount: 0,
elite: 0,
}),
(error: unknown) =>
error instanceof TleilaxuSecretAllyError && error.reason === 'leader',
);
kill(p, [0]);
assert.equal(quoteTleilaxuSecretAlly(g, p, { leader: p.leaders[0].id, amount: 0, elite: 0 }).cost, 0);
p.leaderRevived = true;
assert.deepEqual(tleilaxuSecretAllyOptions(g, p).leaders, []);
assert.throws(
() => quoteTleilaxuSecretAlly(g, p, { leader: p.leaders[0].id, amount: 0, elite: 0 }),
TleilaxuSecretAllyError,
);
});
void test('exact paid typed counters: one bank spice each, physical Tanks, and the Advanced one-per-turn cap', () => {
const g = fixture();
const p = g.players[0];
p.elites!.tanks = 3;
kill(p, [0]);
const offer = tleilaxuSecretAllyOptions(g, p);
assert.equal(offer.maxForces, NEXUS_TLEILAXU_FORCE_GROUP_CAP);
assert.equal(offer.maxElite, 1);
assert.deepEqual(
quoteTleilaxuSecretAlly(g, p, { leader: p.leaders[0].id, amount: 4, elite: 1 }),
{ leader: p.leaders[0].id, kind: 'leader', amount: 4, elite: 1, cost: 4, limit: 5 },
);
assert.throws(
() => quoteTleilaxuSecretAlly(g, p, { leader: p.leaders[0].id, amount: 4, elite: 2 }),
TleilaxuSecretAllyError,
);
assert.throws(
() => quoteTleilaxuSecretAlly(g, p, { leader: p.leaders[0].id, amount: 1, elite: 2 }),
TleilaxuSecretAllyError,
);
assert.throws(
() => quoteTleilaxuSecretAlly(g, p, { leader: p.leaders[0].id, amount: 6, elite: 0 }),
TleilaxuSecretAllyError,
);
p.elites!.tanks = 0;
kill(p, [1, 2, 3, 4]);
p.tanks = 2;
assert.equal(tleilaxuSecretAllyForceBounds(g, p).maxForces, 2);
assert.throws(
() => quoteTleilaxuSecretAlly(g, p, { leader: p.leaders[0].id, amount: 3, elite: 0 }),
TleilaxuSecretAllyError,
);
});
void test('Ixian Cyborgs and Basic typed counters keep identity without the Advanced cap or ordinary pricing', () => {
const advanced = fixture(true, 'ixians');
advanced.players[0].elites = { reserves: 0, tanks: 3, forces: {}, revived: 4 };
kill(advanced.players[0], [0]);
const ix = tleilaxuSecretAllyOptions(advanced, advanced.players[0]);
assert.equal(ix.maxElite, 3);
assert.equal(
quoteTleilaxuSecretAlly(advanced, advanced.players[0], {
leader: advanced.players[0].leaders[0].id,
amount: 5,
elite: 3,
}).cost,
5,
);
const basic = fixture(false, 'emperor');
basic.players[0].elites = { reserves: 0, tanks: 2, forces: {}, revived: 5 };
kill(basic.players[0], [0]);
const emperor = tleilaxuSecretAllyOptions(basic, basic.players[0]);
assert.equal(emperor.maxForces, 5);
assert.equal(emperor.maxElite, 2);
});
void test('the shared ordinary ceiling is raised to five, counts earlier returns, and never doubles or shrinks a native limit', () => {
const g = fixture();
const p = g.players[0];
kill(p, [0]);
p.revived = 4;
assert.equal(tleilaxuSecretAllyOptions(g, p).maxForces, 1);
assert.throws(
() => quoteTleilaxuSecretAlly(g, p, { leader: p.leaders[0].id, amount: 2, elite: 0 }),
TleilaxuSecretAllyError,
);
p.revived = 5;
const spent = tleilaxuSecretAllyOptions(g, p);
assert.equal(spent.maxForces, 0);
assert.equal(spent.blocked, null);
assert.equal(quoteTleilaxuSecretAlly(g, p, { leader: p.leaders[0].id, amount: 0, elite: 0 }).cost, 0);
assert.equal(tleilaxuSecretAllyForceBounds(g, p).ceiling, 5);
// The group is not an independent extra five: its paid counters come out of
// the same ordinary allowance, in either order.
const ordered = fixture();
const holder = ordered.players[0];
kill(holder, [0]);
const ordinary = forceRevivalRemaining(ordered, holder);
assert.equal(ordinary, 3);
const group = quoteTleilaxuSecretAlly(ordered, holder, {
leader: holder.leaders[0].id,
amount: 2,
elite: 0,
});
holder.revived += group.amount;
assert.equal(forceRevivalRemaining(ordered, holder), ordinary - 2);
assert.deepEqual(ordered.revivalRules!.expanded, []);
const choam = fixture(true, 'choam');
kill(choam.players[0], [0]);
choam.players[0].revived = 6;
assert.equal(tleilaxuSecretAllyForceBounds(choam, choam.players[0]).ceiling, 20);
assert.equal(tleilaxuSecretAllyOptions(choam, choam.players[0]).maxForces, NEXUS_TLEILAXU_FORCE_GROUP_CAP);
const recruits = fixture();
kill(recruits.players[0], [0]);
recruits.recruits = { turn: recruits.turn, player: 'h', card: 'ecaz-recruits' };
assert.equal(tleilaxuSecretAllyForceBounds(recruits, recruits.players[0]).ceiling, 7);
assert.equal(tleilaxuSecretAllyOptions(recruits, recruits.players[0]).maxForces, NEXUS_TLEILAXU_FORCE_GROUP_CAP);
assert.equal(
quoteTleilaxuSecretAlly(recruits, recruits.players[0], {
leader: recruits.players[0].leaders[0].id,
amount: 5,
elite: 0,
}).cost,
5,
);
});
void test('a native Fremen free-rate restriction never rejects the explicit paid grant', () => {
const g = fixture();
const p = g.players[0];
p.faction = 'fremen';
p.leaders = leaders('fremen');
p.revived = 3;
p.freeForcesRevived = 3;
kill(p, [0]);
// The ordinary paid Fremen path is closed without Tleilaxu in play.
assert.equal(forceRevivalRemaining(g, p), 0);
assert.equal(
quoteTleilaxuSecretAlly(g, p, { leader: p.leaders[0].id, amount: 2, elite: 0 }).cost,
2,
);
assert.throws(
() => quoteTleilaxuSecretAlly(g, p, { leader: p.leaders[0].id, amount: 3, elite: 0 }),
TleilaxuSecretAllyError,
);
});
void test('Kwisatz and Auditor keep their own canonical return cycles inside the grant', () => {
const g = fixture(true, 'atreides');
const p = g.players[0];
p.kwisatz = { dead: true, revivalCycle: 1 };
const offer = tleilaxuSecretAllyOptions(g, p);
assert.deepEqual(offer.leaders, [
{ id: 'kwisatz', name: 'Kwisatz Haderach', kind: 'kwisatz', early: true },
]);
assert.equal(
quoteTleilaxuSecretAlly(g, p, { leader: 'kwisatz', amount: 0, elite: 0 }).kind,
'kwisatz',
);
p.kwisatz = { dead: true, revivalCycle: 2 };
assert.deepEqual(tleilaxuSecretAllyOptions(g, p).leaders, []);
assert.throws(
() => quoteTleilaxuSecretAlly(g, p, { leader: 'kwisatz', amount: 0, elite: 0 }),
TleilaxuSecretAllyError,
);
p.kwisatz = { dead: true, revivalCycle: 1 };
g.revivalRules!.earlyBlocked.push('a:kwisatz');
assert.deepEqual(tleilaxuSecretAllyOptions(g, p).leaders, []);
g.revivalRules!.earlyBlocked.length = 0;
p.kwisatz = { dead: false, revivalCycle: 1 };
kill(p, [0]);
g.revivalRules!.earlyBlocked.push(`a:${p.leaders[0].id}`);
assert.deepEqual(tleilaxuSecretAllyOptions(g, p).leaders, []);
const choam = fixture(true, 'choam');
const seat = choam.players[0];
const auditor = createAuditorLeader();
auditor.dead = true;
auditor.deaths = 1;
seat.leaders.push(auditor);
kill(seat, [0]);
assert.deepEqual(tleilaxuSecretAllyOptions(choam, seat).leaders, [
{ id: auditor.id, name: 'Auditor', kind: 'leader', early: false },
{ id: seat.leaders[0].id, name: seat.leaders[0].name, kind: 'leader', early: true },
]);
});
void test('the shared Ecaz Duke is one more own identity and its custody blocks are named, not silently narrowed', () => {
const shared = fixture(false, 'ecaz');
shared.dukeVidal = createDukeVidal();
shared.dukeVidal!.leader.dead = true;
shared.dukeVidal!.leader.deaths = 1;
const p = shared.players[0];
const offer = tleilaxuSecretAllyOptions(shared, p);
assert.deepEqual(offer.leaders, [
{ id: DUKE_VIDAL_ID, name: shared.dukeVidal!.leader.name, kind: 'duke', early: false },
]);
assert.equal(
quoteTleilaxuSecretAlly(shared, p, { leader: DUKE_VIDAL_ID, amount: 1, elite: 0 }).cost,
1,
);
const contested = fixture(true, 'ecaz');
contested.dukeVidal = createDukeVidal();
contested.dukeVidal!.leader.dead = true;
contested.dukeVidal!.leader.deaths = 1;
const seat = contested.players[0];
assert.match(tleilaxuSecretAllyOptions(contested, seat).blocked!, /Advanced Harkonnen/);
kill(seat, [0]);
const withLeader = tleilaxuSecretAllyOptions(contested, seat);
assert.equal(withLeader.blocked, null);
assert.ok(withLeader.leaders.every((leader) => leader.kind === 'leader'));
});
void test('living, captured, foreign ghola, wrong-faction and Cheap Hero custody are never candidates', () => {
const g = fixture();
const p = g.players[0];
kill(p, [0]);
p.leaders[1].dead = true;
p.leaders[1].deaths = 1;
p.leaders[1].capturedBy = 'h';
p.leaders[2].dead = true;
p.leaders[2].deaths = 1;
p.leaders[2].gholaBy = 'h';
p.leaders.push({
id: CHEAP_HERO,
name: 'Cheap Hero',
strength: 0,
faction: 'atreides',
dead: true,
deaths: 1,
});
p.leaders.push({
id: 'harkonnen-0',
name: 'Foreign Disc',
strength: 6,
faction: 'harkonnen',
dead: true,
deaths: 1,
});
const before = structuredClone(g);
const offer = tleilaxuSecretAllyOptions(g, p);
assert.deepEqual(
offer.leaders.map((leader) => leader.id),
[p.leaders[0].id],
);
assert.deepEqual(g, before);
for (const id of [CHEAP_HERO, p.leaders[1].id, p.leaders[2].id, 'harkonnen-0', 'nobody'])
assert.throws(
() => quoteTleilaxuSecretAlly(g, p, { leader: id, amount: 0, elite: 0 }),
TleilaxuSecretAllyError,
);
});
void test('every offered candidate and bound quotes successfully and every overage is refused', () => {
const g = fixture();
const p = g.players[0];
p.elites!.tanks = 3;
kill(p, [0, 1]);
const offer = tleilaxuSecretAllyOptions(g, p);
assert.equal(offer.blocked, null);
assert.deepEqual(
offer.leaders.map((leader) => leader.id),
[p.leaders[0].id, p.leaders[1].id],
);
for (const leader of offer.leaders)
for (let amount = 0; amount <= offer.maxForces; amount++)
for (let elite = 0; elite <= Math.min(amount, offer.maxElite); elite++) {
const quote = quoteTleilaxuSecretAlly(g, p, { leader: leader.id, amount, elite });
assert.equal(quote.kind, leader.kind);
assert.equal(quote.cost, amount);
assert.equal(quote.limit, offer.maxForces);
}
const target = offer.leaders[0].id;
assert.throws(
() => quoteTleilaxuSecretAlly(g, p, { leader: target, amount: offer.maxForces + 1, elite: 0 }),
TleilaxuSecretAllyError,
);
assert.throws(
() => quoteTleilaxuSecretAlly(g, p, { leader: target, amount: 1.5, elite: 0 }),
TleilaxuSecretAllyError,
);
assert.throws(
() => quoteTleilaxuSecretAlly(g, p, { leader: target, amount: -1, elite: -1 }),
TleilaxuSecretAllyError,
);
p.spice = 0;
assert.throws(
() => quoteTleilaxuSecretAlly(g, p, { leader: target, amount: 1, elite: 0 }),
(error: unknown) =>
error instanceof TleilaxuSecretAllyError && error.reason === 'price',
);
assert.equal(quoteTleilaxuSecretAlly(g, p, { leader: target, amount: 0, elite: 0 }).cost, 0);
});