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.
250 lines
8.7 KiB
TypeScript
250 lines
8.7 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import {
|
|
applyAction,
|
|
createGame,
|
|
initializeHomeworldGameForAudit,
|
|
joinGame,
|
|
newPlayer,
|
|
viewGame,
|
|
type Action,
|
|
type Game,
|
|
} from '../game/engine';
|
|
import { botActions } from '../game/bots';
|
|
import { DIFFICULTIES, type Difficulty } from '../game/bot-profiles';
|
|
import { homeworldGameIntegrity } from '../game/homeworld-game';
|
|
import { nativeShipmentSources } from '../game/homeworld-options';
|
|
import type { NativeReserveSelections } from '../game/homeworld-native-reserves';
|
|
import { territory } from '../game/board';
|
|
|
|
const emperor = (g: Game) => g.players.find((p) => p.id === 'e')!;
|
|
const reload = (g: Game): Game => JSON.parse(JSON.stringify(g));
|
|
|
|
// Deal and choose traitors through the actual Homeworld setup. Subsequent
|
|
// fixtures reposition phase/opportunity only; every physical force relocation
|
|
// uses an accepted engine action. Keep dealt cards in their physical deck/hand.
|
|
function fixture(advanced = true) {
|
|
let g = createGame(
|
|
'HOMEWORLDBOTS',
|
|
newPlayer('e', 'Emperor', 'emperor'),
|
|
advanced,
|
|
);
|
|
joinGame(g, newPlayer('a', 'Atreides', 'atreides'));
|
|
g = applyAction(g, 'e', { type: 'homeworlds', enabled: true });
|
|
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
|
|
g = initializeHomeworldGameForAudit(g);
|
|
while (g.setupStage === 'traitors') {
|
|
const p = g.players.find((seat) => seat.traitorChoices.length)!;
|
|
g = applyAction(g, p.id, { type: 'traitor', leader: p.traitorChoices[0] });
|
|
}
|
|
assert.equal(g.status, 'playing');
|
|
g.deck.push(...emperor(g).hand.splice(0));
|
|
return movement(g);
|
|
}
|
|
function movement(g: Game, shipped = false) {
|
|
g.phase = 5;
|
|
g.phaseOpening = null;
|
|
g.response = null;
|
|
g.decision = null;
|
|
g.active = 'e';
|
|
g.movementRemaining = ['e', 'a'];
|
|
g.ready = [];
|
|
for (const p of g.players) {
|
|
p.shipped = p.id === 'e' && shipped;
|
|
p.moved = 0;
|
|
}
|
|
homeworldGameIntegrity(g);
|
|
return g;
|
|
}
|
|
function transfer(g: Game, origin: string, normal: number, elite: number) {
|
|
const result = applyAction(g, 'e', {
|
|
type: 'emperorHomeworldMove',
|
|
event: viewGame(g, 'e').homeworldMove!.event,
|
|
origin,
|
|
normal,
|
|
elite,
|
|
});
|
|
homeworldGameIntegrity(result);
|
|
return movement(reload(result));
|
|
}
|
|
function candidates(g: Game, level: Difficulty) {
|
|
const view = viewGame(g, 'e');
|
|
view.players.find((p) => p.id === 'e')!.bot = level;
|
|
const before = structuredClone(view);
|
|
const actions = botActions(view);
|
|
assert.deepEqual(
|
|
view,
|
|
before,
|
|
'bot policy leaves the public projection unchanged',
|
|
);
|
|
assert.ok(actions.length > 0);
|
|
for (const action of actions) {
|
|
const source = structuredClone(g);
|
|
const result = applyAction(g, 'e', action);
|
|
homeworldGameIntegrity(result);
|
|
assert.deepEqual(
|
|
g,
|
|
source,
|
|
`${level}/${action.type} leaves source unchanged`,
|
|
);
|
|
}
|
|
return actions;
|
|
}
|
|
function assertSources(g: Game, action: Action) {
|
|
const me = emperor(g);
|
|
const amount = action.amount as number;
|
|
const elite =
|
|
action.elite === undefined
|
|
? Math.max(0, amount - (me.reserves - me.elites!.reserves))
|
|
: (action.elite as number);
|
|
const sources = action.homeworldSources as NativeReserveSelections;
|
|
assert.ok(sources);
|
|
const homes = viewGame(g, 'e').homeworlds!.worlds!.filter(
|
|
(h) => h.native === 'e',
|
|
);
|
|
assert.deepEqual(Object.keys(sources).sort(), homes.map((h) => h.id).sort());
|
|
const total = { normal: 0, elite: 0 };
|
|
for (const [id, force] of Object.entries(sources)) {
|
|
assert.deepEqual(Object.keys(force).sort(), ['elite', 'normal']);
|
|
const available = homes.find((h) => h.id === id)!.forces.e;
|
|
for (const kind of ['normal', 'elite'] as const) {
|
|
assert.ok(Number.isSafeInteger(force[kind]) && force[kind] >= 0);
|
|
assert.ok(force[kind] <= available[kind]);
|
|
total[kind] += force[kind];
|
|
}
|
|
}
|
|
assert.deepEqual(total, { normal: amount - elite, elite });
|
|
}
|
|
|
|
for (const level of DIFFICULTIES) {
|
|
void test(`${level} ships legally from split native homes with explicit normal and elite custody and private-state invariance`, () => {
|
|
let g = fixture();
|
|
g = transfer(g, 'homeworld:emperor', 4, 0);
|
|
g = transfer(g, 'homeworld:emperor:salusa', 0, 2);
|
|
const actions = candidates(g, level);
|
|
const ships = actions.filter((a) => a.type === 'ship');
|
|
assert.ok(ships.length > 0);
|
|
for (const ship of ships) assertSources(g, ship);
|
|
assert.ok(!actions.some((a) => a.type === 'emperorHomeworldMove'));
|
|
if (level !== 'Easy')
|
|
assert.ok(
|
|
ships.some((ship) => {
|
|
const sources = ship.homeworldSources as NativeReserveSelections;
|
|
return (
|
|
sources['homeworld:emperor'].elite > 0 &&
|
|
sources['homeworld:emperor:salusa'].elite > 0
|
|
);
|
|
}),
|
|
'a typed shipment draws Sardaukar across both native homes',
|
|
);
|
|
|
|
const alternative = reload(g);
|
|
const opponent = alternative.players.find((p) => p.id === 'a')!;
|
|
assert.ok(opponent.hand.length && alternative.deck.length);
|
|
[opponent.hand[0], alternative.deck[0]] = [
|
|
alternative.deck[0],
|
|
opponent.hand[0],
|
|
];
|
|
assert.deepEqual(
|
|
candidates(alternative, level),
|
|
actions,
|
|
'changing the unseen opponent card and deck order does not change candidates',
|
|
);
|
|
const guarded = viewGame(g, 'e');
|
|
guarded.players.find((p) => p.id === 'e')!.bot = level;
|
|
const other = guarded.players.find((p) => p.id === 'a')!;
|
|
for (const key of ['hand', 'traitors', 'prediction'])
|
|
Object.defineProperty(other, key, {
|
|
get() {
|
|
throw new Error('opponent secret read');
|
|
},
|
|
});
|
|
assert.deepEqual(botActions(guarded), actions);
|
|
});
|
|
|
|
void test(`${level} chooses one legal Salusa repair after its actual shipment and never reverses it on a renewed opportunity`, () => {
|
|
let g = transfer(fixture(), 'homeworld:emperor:salusa', 0, 4);
|
|
const ship: Action = {
|
|
type: 'ship',
|
|
territory: 'false_wall_west',
|
|
sector: territory('false_wall_west').sectors[0],
|
|
amount: 1,
|
|
elite: 0,
|
|
homeworldSources: nativeShipmentSources(viewGame(g, 'e'), 1, 0),
|
|
};
|
|
g = applyAction(g, 'e', ship);
|
|
assert.equal(emperor(g).shipped, true);
|
|
const actions = candidates(g, level);
|
|
assert.equal(actions[0].type, 'emperorHomeworldMove');
|
|
assert.equal(
|
|
actions.filter((a) => a.type === 'emperorHomeworldMove').length,
|
|
1,
|
|
);
|
|
assert.equal(actions[0].elite, 1);
|
|
const before = {
|
|
reserves: emperor(g).reserves,
|
|
elites: emperor(g).elites!.reserves,
|
|
spice: emperor(g).spice,
|
|
};
|
|
g = applyAction(g, 'e', actions[0]);
|
|
assert.deepEqual(
|
|
{
|
|
reserves: emperor(g).reserves,
|
|
elites: emperor(g).elites!.reserves,
|
|
spice: emperor(g).spice,
|
|
},
|
|
before,
|
|
);
|
|
assert.equal(emperor(g).moved, 1);
|
|
assert.equal(g.homeworlds!.custody!.salusa!.elite, 2);
|
|
assert.ok(
|
|
!candidates(reload(g), level).some(
|
|
(a) => a.type === 'emperorHomeworldMove',
|
|
),
|
|
);
|
|
assert.ok(
|
|
!candidates(movement(reload(g), true), level).some(
|
|
(a) => a.type === 'emperorHomeworldMove',
|
|
),
|
|
);
|
|
});
|
|
|
|
void test(`${level} Basic Homeworld shipment candidates remain legal with no internal Emperor movement`, () => {
|
|
const g = fixture(false);
|
|
assert.equal(viewGame(g, 'e').homeworldMove, null);
|
|
const actions = candidates(g, level);
|
|
assert.ok(!actions.some((a) => a.type === 'emperorHomeworldMove'));
|
|
const ships = actions.filter((a) => a.type === 'ship');
|
|
assert.ok(ships.length > 0);
|
|
for (const ship of ships) assertSources(g, ship);
|
|
});
|
|
}
|
|
|
|
for (const advanced of [false, true])
|
|
void test(`Easy's omitted elite choice ships forced all-Sardaukar reserves legally in ${advanced ? 'Advanced' : 'Basic'}`, () => {
|
|
let g = fixture(advanced);
|
|
// Supply a shipment budget at this phase checkpoint; physical force custody
|
|
// comes entirely from genuine setup and the following fifteen-force action.
|
|
emperor(g).spice = 20;
|
|
g = applyAction(g, 'e', {
|
|
type: 'ship',
|
|
territory: 'carthag',
|
|
sector: territory('carthag').sectors[0],
|
|
amount: 15,
|
|
elite: 0,
|
|
homeworldSources: nativeShipmentSources(viewGame(g, 'e'), 15, 0),
|
|
});
|
|
g = movement(reload(g));
|
|
assert.equal(emperor(g).reserves, 5);
|
|
assert.equal(emperor(g).elites!.reserves, 5);
|
|
const ships = candidates(g, 'Easy').filter((a) => a.type === 'ship');
|
|
assert.ok(ships.length > 0);
|
|
for (const ship of ships) {
|
|
assert.equal(ship.elite, undefined);
|
|
assertSources(g, ship);
|
|
const next = applyAction(g, 'e', ship);
|
|
assert.equal(emperor(next).elites!.reserves, 5 - (ship.amount as number));
|
|
assert.equal(emperor(next).reserves, emperor(next).elites!.reserves);
|
|
}
|
|
});
|