Files
Dune/tests/homeworld-emperor-move.test.ts
Quintine 28ba1618ba Implement Homeworld mechanics through Caladan reinforcement checkpoint
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.
2026-09-10 08:12:26 +10:00

289 lines
9.0 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import {
quoteEmperorHomeworldMove as quote,
EmperorHomeworldMoveError,
type EmperorHomeworldMoveContext,
type EmperorHomeworldMove,
} from '../game/homeworld-emperor-move';
import {
homeworldForceGroups,
type HomeworldCustody,
} from '../game/homeworld-custody';
function fixture(): {
context: EmperorHomeworldMoveContext;
custody: HomeworldCustody;
} {
return {
context: {
advanced: true,
status: 'playing',
phase: 5,
currentPlayer: 'e',
movesLeft: 1,
players: [
{ id: 'e', faction: 'emperor', reserves: 8, eliteReserves: 3 },
{ id: 'a', faction: 'atreides', reserves: 18, eliteReserves: 0 },
],
},
custody: {
salusa: { normal: 2, elite: 2 },
visitors: { 'homeworld:emperor': { a: { normal: 2, elite: 0 } } },
},
};
}
const move: EmperorHomeworldMove = {
origin: 'homeworld:emperor',
forces: { normal: 2, elite: 1 },
};
function rejected(
context: EmperorHomeworldMoveContext,
custody: HomeworldCustody,
order = move,
actor = 'e',
) {
const before = structuredClone({ context, custody, order });
assert.throws(
() => quote(context, custody, actor, order),
EmperorHomeworldMoveError,
);
assert.deepEqual({ context, custody, order }, before);
}
void test('a mixed Kaitain-to-Salusa transfer spends exactly one movement and changes neither native totals nor foreign custody', () => {
const { context, custody } = fixture();
const before = structuredClone({ context, custody });
const result = quote(context, custody, 'e', move);
assert.equal(result.origin, 'homeworld:emperor');
assert.equal(result.destination, 'homeworld:emperor:salusa');
assert.equal(result.movementSpent, 1);
assert.equal(result.movesLeft, 0);
assert.deepEqual(result.players, context.players);
assert.deepEqual(result.state.salusa, { normal: 4, elite: 3 });
assert.deepEqual(result.state.visitors, custody.visitors);
assert.deepEqual(result.receipts, [
{
homeworld: 'homeworld:emperor',
player: 'e',
before: { normal: 3, elite: 1 },
after: { normal: 1, elite: 0 },
},
{
homeworld: 'homeworld:emperor:salusa',
player: 'e',
before: { normal: 2, elite: 2 },
after: { normal: 4, elite: 3 },
},
]);
assert.deepEqual({ context, custody }, before);
assert.notEqual(result.forces, move.forces);
result.state.visitors['homeworld:emperor'].a.normal = 1;
result.players[0].reserves = 0;
assert.deepEqual({ context, custody }, before);
});
void test('Salusa-to-Kaitain transfers allow normal and Sardaukar counters together and return exact population changes', () => {
const { context, custody } = fixture();
const result = quote(context, custody, 'e', {
origin: 'homeworld:emperor:salusa',
forces: { normal: 2, elite: 1 },
});
assert.deepEqual(result.state.salusa, { normal: 0, elite: 1 });
const native = (side: 'before' | 'after') =>
result.populations[side]
.filter((p) => p.native === 'e')
.map((p) => [
p.card,
p.population,
p.side,
p.nativeBattleStrength,
p.extraFreeRevival,
]);
assert.deepEqual(native('before'), [
['kaitain', 4, 'low', 3, 1],
['salusa_secundus', 2, 'high', 3, 0],
]);
assert.deepEqual(native('after'), [
['kaitain', 7, 'high', 2, 0],
['salusa_secundus', 1, 'low', 2, 0],
]);
assert.deepEqual(
result.populations.after.filter((p) => p.native !== 'e'),
result.populations.before.filter((p) => p.native !== 'e'),
);
assert.deepEqual(result.players, context.players);
});
void test('normal-only transfers do not raise Salusa Sardaukar population and single-counter groups are legal', () => {
for (const forces of [
{ normal: 1, elite: 0 },
{ normal: 0, elite: 1 },
]) {
const { context, custody } = fixture();
const result = quote(context, custody, 'e', {
origin: 'homeworld:emperor',
forces,
});
const salusa = result.populations.after.find(
(p) => p.card === 'salusa_secundus',
)!;
assert.equal(salusa.population, 2 + forces.elite);
assert.equal(result.movesLeft, 0);
}
});
void test('each available whole movement can transfer again after JSON, including an additional Hajr movement', () => {
const { context, custody } = fixture();
context.movesLeft = 2;
const first = quote(context, custody, 'e', move);
const restored = JSON.parse(JSON.stringify(first));
const nextContext = {
...context,
players: restored.players,
movesLeft: restored.movesLeft,
};
const second = quote(nextContext, restored.state, 'e', {
origin: first.destination,
forces: first.forces,
});
assert.equal(first.movesLeft, 1);
assert.equal(second.movesLeft, 0);
assert.deepEqual(second.state, custody);
assert.deepEqual(second.players, context.players);
rejected({ ...context, players: second.players, movesLeft: 0 }, second.state);
});
void test('rejects unavailable movement, wrong actor, wrong phase and Basic or nonplaying source', () => {
for (const change of [
{ advanced: false },
{ phase: 1 },
{ phase: 4 },
{ phase: 6 },
{ status: 'lobby' },
{ status: 'setup' },
{ status: 'finished' },
{ currentPlayer: null },
{ currentPlayer: 'a' },
...[0, -1, 0.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1, '1'].map(
(movesLeft) => ({ movesLeft }),
),
]) {
const { context, custody } = fixture();
rejected({ ...context, ...change } as EmperorHomeworldMoveContext, custody);
}
for (const actor of ['a', 'absent', '']) {
const { context, custody } = fixture();
context.currentPlayer = actor;
rejected(context, custody, move, actor);
}
});
void test('rejects malformed order and unavailable typed source counters without substituting or borrowing visitor units', () => {
const orders: unknown[] = [
null,
{},
{ origin: 'homeworld:atreides', forces: { normal: 1, elite: 0 } },
{ origin: 'arrakeen', forces: { normal: 1, elite: 0 } },
{ ...move, destination: 'homeworld:atreides' },
...[
{ normal: 0, elite: 0 },
{ normal: -1, elite: 1 },
{ normal: 0.5, elite: 0 },
{ normal: NaN, elite: 0 },
{ normal: Infinity, elite: 0 },
{ normal: Number.MAX_SAFE_INTEGER, elite: 1 },
{ normal: 21, elite: 0 },
{ normal: 1 },
{ normal: 1, elite: 0, advisors: true },
{ normal: '1', elite: 0 },
{ normal: 4, elite: 0 },
{ normal: 0, elite: 2 },
].map((forces) => ({ origin: 'homeworld:emperor', forces })),
];
for (const order of orders) {
const { context, custody } = fixture();
rejected(context, custody, order as EmperorHomeworldMove);
}
});
void test('complete shared custody validation rejects corrupt source rosters and Salusa allocations before a quote is issued', () => {
for (const mutate of [
(c: EmperorHomeworldMoveContext, s: HomeworldCustody) => {
s.salusa = null;
},
(c: EmperorHomeworldMoveContext, s: HomeworldCustody) => {
s.salusa!.elite = 4;
},
(c: EmperorHomeworldMoveContext, s: HomeworldCustody) => {
s.salusa!.normal = 6;
},
(c: EmperorHomeworldMoveContext, s: HomeworldCustody) => {
s.visitors['homeworld:emperor'].e = { normal: 1, elite: 0 };
},
(c: EmperorHomeworldMoveContext, s: HomeworldCustody) => {
s.visitors['homeworld:emperor'].a.normal = 3;
},
(c: EmperorHomeworldMoveContext) => {
c.players = [...c.players, { ...c.players[0] }];
},
(c: EmperorHomeworldMoveContext) => {
c.players = c.players.map((p) =>
p.id === 'e' ? { ...p, eliteReserves: 6 } : p,
);
},
]) {
const { context, custody } = fixture();
mutate(context, custody);
rejected(context, custody);
}
});
void test('movement ignores ordinary Emperor cancellation and reads no private state, turn RNG or unrelated action counters', () => {
const { context, custody } = fixture();
const expected = quote(context, custody, 'e', move);
Object.defineProperty(context, 'emperorCanceled', {
value: true,
enumerable: true,
});
assert.deepEqual(quote(context, custody, 'e', move), expected);
for (const key of [
'hands',
'deck',
'rng',
'spice',
'shipped',
'hajr',
'response',
'prediction',
])
Object.defineProperty(context, key, {
enumerable: true,
get() {
throw new Error('unrelated/private context read');
},
});
for (const p of context.players)
for (const key of ['hand', 'spice', 'leaders', 'prediction'])
Object.defineProperty(p, key, {
enumerable: true,
get() {
throw new Error('private seat read');
},
});
const random = Math.random;
Math.random = () => {
throw new Error('randomness read');
};
try {
assert.deepEqual(quote(context, custody, 'e', move), expected);
assert.deepEqual(quote(context, custody, 'e', move), expected);
assert.deepEqual(homeworldForceGroups(context, custody)[0].forces.e, {
normal: 3,
elite: 1,
});
} finally {
Math.random = random;
}
});