Files
Dune/tests/homeworld-revival-destinations.test.ts
T

185 lines
11 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { createGame, newPlayer } from '../game/engine';
import { createHomeworldCustody, homeworldForceGroups } from '../game/homeworld-custody';
import { homeworldContext } from '../game/homeworld-game';
import { homeworldRevivalDestinations, quoteHomeworldRevivalDestination } from '../game/homeworld-revival-destinations';
import type { HomeworldRevivalDeploymentQuote } from '../game/homeworld-revival-deployment';
import { createTerrorState, placeTerror } from '../game/moritani-terror';
import { createRicheseNoField, deployRicheseNoField } from '../game/richese-no-field';
function fixture(faction: 'fremen' | 'tleilaxu', advanced = true) {
const g = createGame('REVIVALDESTINATIONS', newPlayer('p', 'Reviver', faction), advanced);
g.players.push(newPlayer('b', 'BG', 'beneGesserit'), newPlayer('m', 'Moritani', 'moritani'),
newPlayer('v', 'Visitor', 'harkonnen'));
for (const p of g.players) Object.assign(p, { reserves: 10, forces: {}, tanks: 10 });
if (faction === 'tleilaxu') g.players[0].reserves = 11;
g.players[1].reserves = 0;
if (faction === 'fremen') {
g.players[0].elites = { reserves: 2, tanks: 1, forces: {}, revived: 0 };
g.players[0].forces = { 'arrakeen:10': 1, 'polar_sink:0': 1 };
}
g.storm = 18;
g.homeworlds = { custody: createHomeworldCustody(homeworldContext(g)) };
const grant: HomeworldRevivalDeploymentQuote = { kind: faction === 'fremen' ? 'fedaykin' : 'tleilax',
normal: faction === 'tleilaxu' ? 2 : 0, elite: faction === 'fremen' ? 2 : 0,
beforePopulation: faction === 'tleilaxu' ? 9 : 3, afterPopulation: faction === 'tleilaxu' ? 11 : 10, blocked: null };
return { g, grant };
}
void test('Southern subset quotes debit only selected original stars while omitted quantities retain whole-group behavior', () => {
const { g, grant } = fixture('fremen', false), before = structuredClone(g);
const one = quoteHomeworldRevivalDestination(g, 'p', grant, 'arrakeen:10', 1);
assert.equal(one.transfer.players.find(player => player.id === 'p')!.reserves, 9);
assert.equal(one.transfer.players.find(player => player.id === 'p')!.eliteReserves, 1);
const full = quoteHomeworldRevivalDestination(g, 'p', grant, 'polar_sink:0');
assert.equal(full.transfer.players.find(player => player.id === 'p')!.reserves, 8);
assert.equal(full.transfer.players.find(player => player.id === 'p')!.eliteReserves, 0);
for (const amount of [0, -1, 1.5, 3, NaN, Infinity])
assert.throws(() => quoteHomeworldRevivalDestination(g, 'p', grant, 'polar_sink:0', amount));
const t = fixture('tleilaxu', false);
assert.throws(() => quoteHomeworldRevivalDestination(t.g, 'p', t.grant, 'polar_sink:0', 1));
assert.equal(quoteHomeworldRevivalDestination(t.g, 'p', t.grant, 'polar_sink:0').transfer.players
.find(player => player.id === 'p')!.reserves, 9);
assert.deepEqual(g, before);
});
void test('later Southern groups retain the fixed earned grant below threshold but quote current destination presence', () => {
const { g, grant } = fixture('fremen', false);
g.players[0].reserves = 6;
g.homeworlds = { custody: createHomeworldCustody(homeworldContext(g)) };
const originalGrant = { ...grant, beforePopulation: 3, afterPopulation: 6 };
const first = quoteHomeworldRevivalDestination(g, 'p', originalGrant, 'arrakeen:10', 1);
const owner = first.transfer.players.find(player => player.id === 'p')!;
g.players[0].reserves = owner.reserves; g.players[0].elites!.reserves = owner.eliteReserves;
g.homeworlds.custody = first.transfer.state;
assert.equal(g.players[0].reserves, 5);
// A completed first child may change the board; never reuse its destination quote.
g.players[0].forces = { 'polar_sink:0': 1 };
assert.throws(() => quoteHomeworldRevivalDestination(g, 'p', originalGrant, 'arrakeen:10', 1));
const second = quoteHomeworldRevivalDestination(g, 'p', originalGrant, 'polar_sink:0', 1);
assert.equal(second.transfer.players.find(player => player.id === 'p')!.eliteReserves, 0);
assert.deepEqual(originalGrant, { ...grant, beforePopulation: 3, afterPopulation: 6 });
});
void test('Southern lists existing territories only and withdraws the entire typed star group without mutation', () => {
const { g, grant } = fixture('fremen', false);
const before = structuredClone(g);
const choices = homeworldRevivalDestinations(g, 'p', grant);
assert.deepEqual(new Set(choices.map((c) => c.territory)), new Set(['arrakeen', 'polar_sink']));
assert.ok(choices.every((c) => !c.homeworld));
const quote = quoteHomeworldRevivalDestination(g, 'p', grant, 'polar_sink:0');
const native = quote.transfer.players.find((p) => p.id === 'p')!;
assert.equal(native.reserves, 8);
assert.equal(native.eliteReserves, 0);
assert.deepEqual(g, before);
});
void test('Tleilax SEND destinations remain available before their original Spiritual Advisor continuation', () => {
const { g, grant } = fixture('tleilaxu');
g.players[1].reserves = 20;
const choices = homeworldRevivalDestinations(g, 'p', grant);
assert.equal(choices.find((c) => c.id === 'polar_sink:0')!.blocked, null);
const home = choices.filter((c) => c.homeworld);
assert.ok(home.length > 0);
assert.ok(home.every((c) => c.blocked === null));
assert.doesNotThrow(() => quoteHomeworldRevivalDestination(g, 'p', grant, 'homeworld:tleilaxu'));
g.players[1].reserves = 1;
assert.equal(homeworldRevivalDestinations(g, 'p', grant).find((c) => c.id === 'polar_sink:0')!.blocked, null);
});
void test('Southern on-planet placement has no off-planet BG accompaniment gate', () => {
const { g, grant } = fixture('fremen');
g.players[1].reserves = 20;
assert.equal(homeworldRevivalDestinations(g, 'p', grant).find((c) => c.id === 'polar_sink:0')!.blocked, null);
});
void test('Tleilax Homeworld transfers preserve exact custody, permit own-world no-op and reject ally destinations', () => {
const { g, grant } = fixture('tleilaxu');
const before = structuredClone(g);
const own = quoteHomeworldRevivalDestination(g, 'p', grant, 'homeworld:tleilaxu');
assert.equal(own.transfer.players.find((p) => p.id === 'p')!.reserves, 11);
assert.deepEqual(own.transfer.state, g.homeworlds!.custody);
const foreign = quoteHomeworldRevivalDestination(g, 'p', grant, 'homeworld:harkonnen');
const home = homeworldForceGroups({ advanced: g.advanced, players: foreign.transfer.players }, foreign.transfer.state)
.find((h) => h.native === 'v')!;
assert.deepEqual(home.forces.p, { normal: 2, elite: 0 });
assert.equal(foreign.transfer.players.find((p) => p.id === 'p')!.reserves, 9);
assert.deepEqual(g, before);
g.players[0].ally = 'v'; g.players[3].ally = 'p';
const allied = structuredClone(g);
assert.throws(() => quoteHomeworldRevivalDestination(g, 'p', grant, 'homeworld:harkonnen'), /ally/);
assert.deepEqual(g, allied);
});
void test('revival keeps storm and HMS route restrictions distinct, including the sheltered interior', () => {
for (const faction of ['fremen', 'tleilaxu'] as const) {
const { g, grant } = fixture(faction);
g.storm = 10;
g.mobileStronghold = { location: 'arrakeen:10' } as NonNullable<typeof g.mobileStronghold>;
g.players[0].forces['hidden_mobile_stronghold:0'] = 1;
let choices = homeworldRevivalDestinations(g, 'p', grant);
assert.match(choices.find((c) => c.id === 'arrakeen:10')!.blocked!, /storm/);
const route = faction === 'fremen' ? /Force Movement/ : /Only Ixians/;
assert.match(choices.find((c) => c.id === 'hidden_mobile_stronghold:0')!.blocked!, route);
assert.doesNotMatch(choices.find((c) => c.id === 'hidden_mobile_stronghold:0')!.blocked!, /storm|await|ruling/);
assert.equal(choices.find((c) => c.id === 'polar_sink:0')!.blocked, null);
g.storm = 18;
choices = homeworldRevivalDestinations(g, 'p', grant);
assert.match(choices.find((c) => c.id === 'hidden_mobile_stronghold:0')!.blocked!, route);
const before = structuredClone(g);
assert.throws(() => quoteHomeworldRevivalDestination(g, 'p', grant, 'hidden_mobile_stronghold:0'), route);
assert.deepEqual(g, before);
}
});
void test('BG fighters do not refuse SEND destinations before their original Intrusion continuation', () => {
const { g, grant } = fixture('fremen');
g.players[1].forces = { 'arrakeen:10': 1 };
const target = () => homeworldRevivalDestinations(g, 'p', grant).find((c) => c.id === 'arrakeen:10')!;
assert.equal(target().blocked, null);
g.advanced = false;
assert.equal(target().blocked, null);
g.advanced = true;
g.players[1].advisors = { arrakeen: {} };
assert.equal(target().blocked, null);
});
void test('revival Terror destinations remain available without reading private token faces', () => {
const { g, grant } = fixture('tleilaxu');
g.moritaniTerror = createTerrorState(() => 0);
const token = g.moritaniTerror.tokens[0];
g.moritaniTerror = placeTerror(g.moritaniTerror, token.id, 'arrakeen', 1);
for (const t of g.moritaniTerror.tokens) Object.defineProperty(t, 'kind', { get() { throw new Error('private Terror face read'); } });
const target = () => homeworldRevivalDestinations(g, 'p', grant).find((c) => c.id === 'arrakeen:10')!;
assert.equal(target().blocked, null);
g.players[2].reserves = 7;
assert.equal(target().blocked, null);
grant.normal = 3;
assert.equal(target().blocked, null);
g.players[2].ally = 'p'; g.players[0].ally = 'm';
assert.equal(target().blocked, null);
});
void test('destination quotes do not inspect opposing private cards or spice', () => {
const { g, grant } = fixture('fremen');
for (const p of g.players) for (const field of ['hand', 'spice', 'traitors'])
Object.defineProperty(p, field, { get() { throw new Error(`private ${field} read`); } });
assert.doesNotThrow(() => quoteHomeworldRevivalDestination(g, 'p', grant, 'polar_sink:0'));
});
void test('a concealed No-Field fills a stronghold without exposing its denomination', () => {
const { g, grant } = fixture('tleilaxu', false);
g.players[3] = newPlayer('v', 'Richese', 'richese');
g.players[3].reserves = 20;
g.players[3].noField = deployRicheseNoField(createRicheseNoField(['zero', 'three', 'five']),
{ tokenId: 'zero', controller: 'v', location: { territory: 'arrakeen', sector: 10 } });
g.players[2].forces = { 'arrakeen:10': 1 };
g.homeworlds = { custody: createHomeworldCustody(homeworldContext(g)) };
for (const token of g.players[3].noField.tokens)
Object.defineProperty(token, 'value', { get() { throw new Error('private denomination read'); } });
assert.throws(() => quoteHomeworldRevivalDestination(g, 'p', grant, 'arrakeen:10'), /three occupying factions/);
g.players[2].forces = {};
assert.doesNotThrow(() => quoteHomeworldRevivalDestination(g, 'p', grant, 'arrakeen:10'));
});