Connect typed Homeworld custody, transport, battle, income, revival and Caladan reinforcement flows with private multiplayer recovery and controls. Preserve the optimized test workflow and keep unfinished rules modes gated. Verified with 3682 offline tests, typecheck, lint, production build and 40 HTTP tests; existing saved games remain unchanged.
218 lines
7.6 KiB
TypeScript
218 lines
7.6 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; sector choices stay at the original battle and storm/HMS remain explicitly blocked', () => {
|
||
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.match(
|
||
quote(g, victory(MOBILE_STRONGHOLD)).destinations[0].blocked!,
|
||
/Hidden Mobile Stronghold/,
|
||
);
|
||
});
|
||
|
||
void test('actual entry boundaries retain occupancy, BG Intrusion and public low-Grumman count distinctions', () => {
|
||
const g = fixture();
|
||
const bg = newPlayer('b', 'BG', 'beneGesserit');
|
||
bg.forces = { 'arrakeen:10': 1 };
|
||
bg.reserves = 19;
|
||
g.players.push(bg);
|
||
assert.match(quote(g, victory()).destinations[0].blocked!, /Intrusion/);
|
||
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.match(quote(g, victory()).destinations[0].blocked!, /Terror/);
|
||
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,
|
||
);
|
||
});
|