216 lines
7.7 KiB
TypeScript
216 lines
7.7 KiB
TypeScript
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,
|
||
);
|
||
});
|