Files
Dune/tests/homeworld-victory-reinforcement.test.ts
T

216 lines
7.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import test from 'node:test';
import assert from 'node:assert/strict';
import { newPlayer } from '../game/engine';
import { location, territory, MOBILE_STRONGHOLD } from '../game/board';
import { createHomeworldCustody } from '../game/homeworld-custody';
import { homeworldContext } from '../game/homeworld-game';
import { createTerrorState, placeTerror } from '../game/moritani-terror';
import {
quoteHomeworldVictoryReinforcement as quote,
quoteHomeworldVictoryReinforcementDestination as transfer,
type HomeworldVictoryWitness,
type HomeworldVictoryReinforcementContext,
} from '../game/homeworld-victory-reinforcement';
function fixture(
population = 6,
advanced = true,
): HomeworldVictoryReinforcementContext {
const players = [
newPlayer('a', 'Atreides', 'atreides'),
newPlayer('h', 'Harkonnen', 'harkonnen'),
];
players[0].reserves = population;
players[0].forces = { 'arrakeen:10': 2 };
return {
advanced,
players,
storm: 18,
homeworlds: {
custody: createHomeworldCustody(homeworldContext({ advanced, players })),
},
};
}
const victory = (
territory = 'arrakeen',
result: 'normal' | 'traitor' = 'normal',
): HomeworldVictoryWitness => ({
event: 'battle:2:1',
turn: 2,
player: 'a',
territory,
result,
});
void test('high Caladan quotes one physical native force in both rules modes and for normal or traitor victory', () => {
for (const advanced of [false, true])
for (const result of ['normal', 'traitor'] as const) {
const g = fixture(6, advanced);
const before = structuredClone(g);
const resultQuote = quote(g, victory('arrakeen', result));
assert.equal(resultQuote.population, 6);
assert.equal(resultQuote.survivors, 2);
assert.equal(resultQuote.amount, 1);
assert.deepEqual(
resultQuote.destinations.map((d) => d.id),
['arrakeen:10'],
);
const moved = transfer(g, victory('arrakeen', result), 'arrakeen:10');
assert.equal(
moved.transfer.players.find((p) => p.id === 'a')!.reserves,
5,
);
assert.deepEqual(g, before);
}
});
void test('current native threshold and surviving own forces exclude low, empty, foreign visitors and another faction’s win', () => {
const g = fixture(5);
g.homeworlds!.custody!.visitors['homeworld:atreides'] = {
h: { normal: 10, elite: 0 },
};
g.players[1].reserves = 10;
assert.equal(quote(g, victory()).amount, 0);
g.players[0].reserves = 6;
g.players[0].forces = {};
g.players[1].forces = { 'arrakeen:10': 1 };
assert.equal(quote(g, victory()).amount, 0);
assert.equal(quote(g, { ...victory(), player: 'h' }).amount, 0);
g.homeworlds = null;
g.players[0].forces = { 'arrakeen:10': 1 };
assert.equal(quote(g, victory()).amount, 0);
});
void test('foreign Homeworld reinforcement transfers exactly one native force to the existing victorious garrison', () => {
const g = fixture();
g.homeworlds!.custody!.visitors['homeworld:harkonnen'] = {
a: { normal: 2, elite: 0 },
};
const witness = victory('homeworld:harkonnen');
assert.equal(quote(g, witness).survivors, 2);
const next = transfer(g, witness, 'homeworld:harkonnen').transfer;
assert.equal(next.players.find((p) => p.id === 'a')!.reserves, 5);
assert.deepEqual(next.state.visitors['homeworld:harkonnen'].a, {
normal: 3,
elite: 0,
});
assert.equal(
g.homeworlds!.custody!.visitors['homeworld:harkonnen'].a.normal,
2,
);
delete g.homeworlds!.custody!.visitors['homeworld:harkonnen'];
assert.equal(quote(g, witness).amount, 0);
});
void test('victory on native Caladan is a physical no-op, never a newly created or revived counter', () => {
const g = fixture();
const before = structuredClone(g);
const result = quote(g, victory('homeworld:atreides'));
assert.equal(result.amount, 0);
assert.equal(result.survivors, 6);
assert.deepEqual(result.destinations, []);
assert.match(result.blocked!, /already on Caladan/);
assert.throws(() =>
transfer(g, victory('homeworld:atreides'), 'homeworld:atreides'),
);
assert.deepEqual(g, before);
});
void test('permission is territorial; storm stays unavailable while the separate addition can reach a won HMS', () => {
const g = fixture();
const sectors = territory('imperial_basin').sectors;
g.players[0].forces = { [location('imperial_basin', sectors[0])]: 1 };
g.storm = sectors[1];
const options = quote(g, victory('imperial_basin')).destinations;
assert.equal(options.length, sectors.length);
assert.match(options.find((d) => d.sector === g.storm)!.blocked!, /storm/);
assert.equal(options.find((d) => d.sector !== g.storm)!.blocked, null);
assert.throws(() => transfer(g, victory('imperial_basin'), 'arrakeen:10'));
g.mobileStronghold = { owner: 'h', location: 'arrakeen:10' } as NonNullable<
HomeworldVictoryReinforcementContext['mobileStronghold']
>;
g.players[0].forces = { [location(MOBILE_STRONGHOLD, 0)]: 1 };
assert.equal(quote(g, victory(MOBILE_STRONGHOLD)).destinations[0].blocked, null);
assert.equal(transfer(g, victory(MOBILE_STRONGHOLD), `${MOBILE_STRONGHOLD}:0`).transfer.players[0].reserves, 5);
});
void test('arrival reactions do not prohibit the original addition; ordinary allied occupancy still does', () => {
const g = fixture();
const bg = newPlayer('b', 'BG', 'beneGesserit');
bg.forces = { 'arrakeen:10': 1 };
bg.reserves = 19;
g.players.push(bg);
assert.equal(quote(g, victory()).destinations[0].blocked, null);
g.players.pop();
const moritani = newPlayer('m', 'Moritani', 'moritani');
moritani.reserves = 8;
g.players.push(moritani);
let state = createTerrorState(() => 0);
const token = state.tokens[0];
state = placeTerror(state, token.id, 'arrakeen', 1);
g.moritaniTerror = state;
assert.equal(quote(g, victory()).destinations[0].blocked, null);
moritani.reserves = 7;
assert.equal(quote(g, victory()).destinations[0].blocked, null);
g.players[0].ally = 'h';
g.players[1].ally = 'a';
g.players[1].forces = { 'arrakeen:10': 1 };
assert.match(quote(g, victory()).destinations[0].blocked!, /ally/);
});
void test('malformed witnesses, physical forces and unknown locations reject without input mutation', () => {
const g = fixture();
for (const change of [
{ event: '' },
{ turn: 0 },
{ turn: NaN },
{ player: 'absent' },
{ result: 'explosion' },
{ territory: 'homeworld:missing' },
{ territory: 'arrakeen:10' },
{ extra: true },
]) {
const bad = { ...victory(), ...change } as HomeworldVictoryWitness;
const before = structuredClone(g);
assert.throws(() => quote(g, bad));
assert.deepEqual(g, before);
}
for (const count of [-1, 0.5, 21]) {
g.players[0].forces = { 'arrakeen:10': count };
assert.throws(() => quote(g, victory()));
}
});
void test('public-only quoting and transfer do not read private hands, spice, Tanks or hidden No-Field size', () => {
const g = fixture();
const richese = newPlayer('r', 'Richese', 'richese');
Object.defineProperty(richese, 'noField', {
value: {
deployed: {
location: { territory: 'carthag', sector: 11 },
get size() {
throw new Error('private No-Field size');
},
get count() {
throw new Error('private No-Field count');
},
},
},
});
g.players.push(richese);
for (const player of g.players)
for (const key of ['hand', 'spice', 'tanks', 'traitors', 'leaders'])
Object.defineProperty(player, key, {
get() {
throw new Error(`private ${key}`);
},
});
const expected = quote(g, victory());
assert.equal(expected.amount, 1);
Object.freeze(g.players[0].forces);
assert.equal(
transfer(g, victory(), 'arrakeen:10').transfer.players[0].reserves,
5,
);
});