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.
290 lines
8.9 KiB
TypeScript
290 lines
8.9 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import type { GameView } from '../game/engine';
|
|
import {
|
|
homeworldForceGroups,
|
|
type HomeworldCustody,
|
|
} from '../game/homeworld-custody';
|
|
import { homeworldPopulations } from '../game/homeworld-population';
|
|
import { quoteNativeReserveWithdrawal } from '../game/homeworld-native-reserves';
|
|
import {
|
|
quoteEmperorHomeworldMove,
|
|
type EmperorHomeworld,
|
|
} from '../game/homeworld-emperor-move';
|
|
import {
|
|
nativeShipmentSources,
|
|
nativeReserveSources,
|
|
withNativeShipmentSources,
|
|
emperorHomeworldMoveActions,
|
|
} from '../game/homeworld-options';
|
|
|
|
function fixture(reserves = 12, eliteReserves = 3, normal = 2, elite = 1) {
|
|
const context = {
|
|
advanced: true,
|
|
players: [
|
|
{ id: 'e', faction: 'emperor' as const, reserves, eliteReserves },
|
|
{ id: 'a', faction: 'atreides' as const, reserves: 18, eliteReserves: 0 },
|
|
],
|
|
};
|
|
const custody: HomeworldCustody = {
|
|
salusa: { normal, elite },
|
|
visitors: { 'homeworld:emperor': { a: { normal: 2, elite: 0 } } },
|
|
};
|
|
const populations = homeworldPopulations(context, custody);
|
|
const view = {
|
|
me: 'e',
|
|
active: 'e',
|
|
advanced: true,
|
|
status: 'playing',
|
|
phase: 5,
|
|
homeworldMove: { event: 'movement:1', remaining: 2, blocked: null },
|
|
players: context.players.map((p) => ({
|
|
...p,
|
|
elites: { reserves: p.eliteReserves },
|
|
})),
|
|
homeworlds: {
|
|
worlds: homeworldForceGroups(context, custody).map((home) => ({
|
|
...home,
|
|
...populations.find((p) => p.location === home.id)!,
|
|
})),
|
|
},
|
|
} as unknown as GameView;
|
|
return { context, custody, view };
|
|
}
|
|
|
|
void test('shipment source allocations preserve both physical types, exclude visitors, and remain deterministic', () => {
|
|
const { context, custody, view } = fixture();
|
|
const before = structuredClone(view);
|
|
const sources = nativeShipmentSources(view, 10, 3)!;
|
|
assert.deepEqual(sources, {
|
|
'homeworld:emperor': { normal: 7, elite: 2 },
|
|
'homeworld:emperor:salusa': { normal: 0, elite: 1 },
|
|
});
|
|
const quoted = quoteNativeReserveWithdrawal(
|
|
context,
|
|
custody,
|
|
'e',
|
|
{ normal: 7, elite: 3 },
|
|
sources,
|
|
);
|
|
assert.equal(quoted.players[0].reserves, 2);
|
|
assert.equal(quoted.players[0].eliteReserves, 0);
|
|
assert.deepEqual(quoted.state.visitors, custody.visitors);
|
|
assert.deepEqual(view, before);
|
|
view.homeworlds!.worlds!.reverse();
|
|
assert.deepEqual(nativeShipmentSources(view, 10, 3), sources);
|
|
assert.equal(
|
|
nativeShipmentSources(view, 10, 0),
|
|
null,
|
|
'visitors cannot fund native normals',
|
|
);
|
|
assert.equal(nativeShipmentSources(view, 4, 4), null);
|
|
});
|
|
|
|
void test('server shipment witnesses share source choices without projecting a GameView and reject corrupt custody immutably', () => {
|
|
const { context, custody, view } = fixture();
|
|
assert.deepEqual(
|
|
nativeReserveSources(context, custody, 'e', { normal: 7, elite: 3 }),
|
|
nativeShipmentSources(view, 10, 3),
|
|
);
|
|
custody.salusa!.elite = 4;
|
|
const before = structuredClone({ context, custody });
|
|
assert.equal(
|
|
nativeReserveSources(context, custody, 'e', { normal: 1, elite: 0 }),
|
|
null,
|
|
);
|
|
assert.deepEqual({ context, custody }, before);
|
|
});
|
|
|
|
void test('zero and single-home native allocations are explicit while disabled and malformed requests are unavailable', () => {
|
|
const { view } = fixture();
|
|
assert.deepEqual(nativeShipmentSources(view, 0), {
|
|
'homeworld:emperor': { normal: 0, elite: 0 },
|
|
'homeworld:emperor:salusa': { normal: 0, elite: 0 },
|
|
});
|
|
view.me = 'a';
|
|
assert.deepEqual(nativeShipmentSources(view, 3), {
|
|
'homeworld:atreides': { normal: 3, elite: 0 },
|
|
});
|
|
for (const [amount, elite] of [
|
|
[-1, 0],
|
|
[1.5, 0],
|
|
[21, 0],
|
|
[3, 4],
|
|
[2, -1],
|
|
[NaN, 0],
|
|
])
|
|
assert.equal(nativeShipmentSources(view, amount, elite), null);
|
|
view.homeworlds = null;
|
|
assert.equal(nativeShipmentSources(view, 0), null);
|
|
});
|
|
|
|
void test('shipment action decoration matches engine elite defaults and preserves valid explicit player allocations', () => {
|
|
const { view } = fixture();
|
|
const implicit = withNativeShipmentSources(view, {
|
|
type: 'ship',
|
|
amount: 11,
|
|
});
|
|
assert.deepEqual(implicit?.homeworldSources, {
|
|
'homeworld:emperor': { normal: 7, elite: 2 },
|
|
'homeworld:emperor:salusa': { normal: 2, elite: 0 },
|
|
});
|
|
const explicit = {
|
|
type: 'ship',
|
|
amount: 1,
|
|
elite: 0,
|
|
homeworldSources: { 'homeworld:emperor:salusa': { normal: 1, elite: 0 } },
|
|
};
|
|
assert.deepEqual(withNativeShipmentSources(view, explicit), explicit);
|
|
assert.equal(
|
|
withNativeShipmentSources(view, { ...explicit, homeworldSources: null }),
|
|
null,
|
|
);
|
|
assert.equal(
|
|
withNativeShipmentSources(view, {
|
|
...explicit,
|
|
homeworldSources: { 'homeworld:atreides': { normal: 1, elite: 0 } },
|
|
}),
|
|
null,
|
|
);
|
|
for (const action of [
|
|
{ type: 'move' },
|
|
{ type: 'ship', noField: 'token' },
|
|
{ type: 'noFieldAllyOffer' },
|
|
])
|
|
assert.equal(withNativeShipmentSources(view, action), action);
|
|
view.homeworlds = null;
|
|
const ordinary = { type: 'ship', amount: 3 };
|
|
assert.equal(withNativeShipmentSources(view, ordinary), ordinary);
|
|
});
|
|
|
|
void test('Emperor restores Salusa with the minimum Sardaukar and spends one movement without touching visitors or native totals', () => {
|
|
const { context, custody, view } = fixture();
|
|
const before = structuredClone(view);
|
|
const actions = emperorHomeworldMoveActions(view);
|
|
assert.deepEqual(actions, [
|
|
{
|
|
type: 'emperorHomeworldMove',
|
|
event: 'movement:1',
|
|
origin: 'homeworld:emperor',
|
|
normal: 0,
|
|
elite: 1,
|
|
},
|
|
]);
|
|
const action = actions[0];
|
|
const result = quoteEmperorHomeworldMove(
|
|
{
|
|
...context,
|
|
status: 'playing',
|
|
phase: 5,
|
|
currentPlayer: 'e',
|
|
movesLeft: 2,
|
|
},
|
|
custody,
|
|
'e',
|
|
{
|
|
origin: action.origin as EmperorHomeworld,
|
|
forces: {
|
|
normal: action.normal as number,
|
|
elite: action.elite as number,
|
|
},
|
|
},
|
|
);
|
|
assert.equal(result.movesLeft, 1);
|
|
assert.deepEqual(result.players, context.players);
|
|
assert.deepEqual(result.state.visitors, custody.visitors);
|
|
assert.equal(
|
|
result.populations.after.filter(
|
|
(p) => p.native === 'e' && p.side === 'high',
|
|
).length,
|
|
2,
|
|
);
|
|
assert.deepEqual(view, before);
|
|
assert.deepEqual(emperorHomeworldMoveActions(fixture(12, 3, 2, 2).view), []);
|
|
});
|
|
|
|
void test('normal returns restore Kaitain without reducing Salusa; competing deficits favor Salusa and never oscillate', () => {
|
|
assert.deepEqual(emperorHomeworldMoveActions(fixture(9, 3, 3, 2).view), [
|
|
{
|
|
type: 'emperorHomeworldMove',
|
|
event: 'movement:1',
|
|
origin: 'homeworld:emperor:salusa',
|
|
normal: 1,
|
|
elite: 0,
|
|
},
|
|
]);
|
|
assert.deepEqual(emperorHomeworldMoveActions(fixture(9, 3, 1, 2).view), []);
|
|
const first = emperorHomeworldMoveActions(fixture(7, 3, 2, 1).view);
|
|
assert.equal(first[0].origin, 'homeworld:emperor');
|
|
const second = emperorHomeworldMoveActions(fixture(7, 3, 2, 2).view);
|
|
assert.equal(second[0].normal, 2);
|
|
assert.equal(second[0].elite, 0);
|
|
assert.deepEqual(emperorHomeworldMoveActions(fixture(7, 3, 0, 2).view), []);
|
|
assert.deepEqual(
|
|
emperorHomeworldMoveActions(fixture(6, 3, 2, 2).view),
|
|
[],
|
|
'insufficient normal return never undoes a repaired Salusa',
|
|
);
|
|
assert.deepEqual(
|
|
emperorHomeworldMoveActions(fixture(4, 1, 2, 0).view),
|
|
[],
|
|
'a move must actually restore a high threshold',
|
|
);
|
|
});
|
|
|
|
void test('blocked, Basic, other actor, exhausted and wrong phase views produce no Emperor candidates', () => {
|
|
for (const patch of [
|
|
{ advanced: false },
|
|
{ active: 'a' },
|
|
{ me: 'a' },
|
|
{ phase: 4 },
|
|
{ status: 'setup' },
|
|
{ homeworlds: null },
|
|
{ homeworldMove: null },
|
|
{ homeworldMove: { event: 'movement:1', remaining: 0, blocked: null } },
|
|
{
|
|
homeworldMove: {
|
|
event: 'movement:1',
|
|
remaining: 2,
|
|
blocked: 'A response is pending.',
|
|
},
|
|
},
|
|
]) {
|
|
const { view } = fixture();
|
|
assert.deepEqual(
|
|
emperorHomeworldMoveActions({ ...view, ...patch } as GameView),
|
|
[],
|
|
);
|
|
}
|
|
});
|
|
|
|
void test('public-view options are invariant to private hands and unrelated state and never read randomness', () => {
|
|
const { view } = fixture();
|
|
const expectedSources = nativeShipmentSources(view, 5, 1);
|
|
const expectedMoves = emperorHomeworldMoveActions(view);
|
|
for (const object of [view, ...view.players])
|
|
for (const key of [
|
|
'hand',
|
|
'deck',
|
|
'rng',
|
|
'prediction',
|
|
'traitors',
|
|
'plans',
|
|
])
|
|
Object.defineProperty(object, key, {
|
|
get() {
|
|
throw new Error('private field read');
|
|
},
|
|
});
|
|
const random = Math.random;
|
|
Math.random = () => {
|
|
throw new Error('randomness read');
|
|
};
|
|
try {
|
|
assert.deepEqual(nativeShipmentSources(view, 5, 1), expectedSources);
|
|
assert.deepEqual(emperorHomeworldMoveActions(view), expectedMoves);
|
|
} finally {
|
|
Math.random = random;
|
|
}
|
|
});
|