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.
420 lines
15 KiB
TypeScript
420 lines
15 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { FACTIONS, type FactionId } from '../game/catalog';
|
|
import {
|
|
createHomeworldCustody as create,
|
|
homeworldForceGroups as groups,
|
|
homeworldLocations as locations,
|
|
quoteHomeworldCustody as quote,
|
|
HomeworldCustodyError,
|
|
type HomeworldCustodyContext as Context,
|
|
type HomeworldCustody as State,
|
|
type HomeworldCustodyChange as Change,
|
|
} from '../game/homeworld-custody';
|
|
|
|
const caps = { emperor: 5, fremen: 3, ixians: 7 };
|
|
const cap = (f: FactionId) => caps[f as keyof typeof caps] ?? 0;
|
|
const home = (f: FactionId) => `homeworld:${f}`;
|
|
const salusa = 'homeworld:emperor:salusa';
|
|
const units = (normal = 0, elite = 0) => ({ normal, elite });
|
|
function context(
|
|
advanced = true,
|
|
factions: FactionId[] = ['emperor', 'atreides', 'fremen'],
|
|
): Context {
|
|
return {
|
|
advanced,
|
|
players: factions.map((f) => ({
|
|
id: f,
|
|
faction: f,
|
|
reserves: 20,
|
|
eliteReserves: cap(f),
|
|
})),
|
|
};
|
|
}
|
|
function change(
|
|
homeworld: string,
|
|
player: string,
|
|
withdraw = units(),
|
|
deposit = units(),
|
|
): Change {
|
|
return { homeworld, player, withdraw, deposit };
|
|
}
|
|
function native(g: Context, state: State, location: string, id: string) {
|
|
return groups(g, state).find((h) => h.id === location)!.forces[id];
|
|
}
|
|
function apply(g: Context, state: State, changes: Change[]) {
|
|
const before = structuredClone({ g, state, changes });
|
|
const result = quote(g, state, changes);
|
|
assert.deepEqual({ g, state, changes }, before);
|
|
return {
|
|
g: { advanced: g.advanced, players: result.players },
|
|
state: result.state,
|
|
receipts: result.receipts,
|
|
};
|
|
}
|
|
function physical(g: Context, state: State, id: string) {
|
|
return groups(g, state).reduce(
|
|
(n, h) => n + (h.forces[id]?.normal ?? 0) + (h.forces[id]?.elite ?? 0),
|
|
0,
|
|
);
|
|
}
|
|
function reject(g: Context, state: State, changes: Change[] = []) {
|
|
const before = structuredClone({ g, state, changes });
|
|
assert.throws(() => quote(g, state, changes), HomeworldCustodyError);
|
|
assert.deepEqual({ g, state, changes }, before);
|
|
}
|
|
|
|
for (const advanced of [false, true])
|
|
void test(`${advanced ? 'Advanced' : 'Basic'}: all 57 base rosters create only seated Homeworlds with conserved native custody`, () => {
|
|
const base = FACTIONS.filter((f) => f.expansion === 'base').map(
|
|
(f) => f.id,
|
|
);
|
|
let checked = 0;
|
|
for (let mask = 0; mask < 1 << base.length; mask++) {
|
|
const roster = base.filter((_, i) => mask & (1 << i));
|
|
if (roster.length < 2) continue;
|
|
const g = context(advanced, roster),
|
|
before = structuredClone(g);
|
|
const state = create(g),
|
|
list = locations(g),
|
|
pools = groups(g, state);
|
|
assert.equal(
|
|
list.length,
|
|
roster.length + Number(advanced && roster.includes('emperor')),
|
|
);
|
|
assert.equal(new Set(list.map((h) => h.id)).size, list.length);
|
|
assert.deepEqual(
|
|
pools.map(({ forces: _, ...h }) => h),
|
|
list,
|
|
);
|
|
assert.deepEqual(state.visitors, {});
|
|
for (const p of g.players) assert.equal(physical(g, state, p.id), 20);
|
|
assert.deepEqual(g, before);
|
|
checked++;
|
|
}
|
|
assert.equal(checked, 57);
|
|
});
|
|
|
|
void test('every expansion faction receives its own home without adding unseated component locations', () => {
|
|
for (const f of FACTIONS.filter((f) => f.expansion !== 'base')) {
|
|
const g = context(true, ['atreides', f.id]);
|
|
assert.deepEqual(
|
|
locations(g).map((h) => h.id),
|
|
[home('atreides'), home(f.id)],
|
|
);
|
|
assert.deepEqual(
|
|
native(g, create(g), home(f.id), f.id),
|
|
units(20 - cap(f.id), cap(f.id)),
|
|
);
|
|
}
|
|
});
|
|
|
|
void test('Advanced initial Sardaukar are in Salusa; Basic typed reserves remain at the single native home', () => {
|
|
for (const advanced of [false, true]) {
|
|
const g = context(advanced),
|
|
state = create(g);
|
|
assert.deepEqual(state.salusa, advanced ? units(0, 5) : null);
|
|
assert.deepEqual(
|
|
native(g, state, home('emperor'), 'emperor'),
|
|
advanced ? units(15) : units(15, 5),
|
|
);
|
|
if (advanced)
|
|
assert.deepEqual(native(g, state, salusa, 'emperor'), units(0, 5));
|
|
}
|
|
});
|
|
|
|
void test('both Emperor force types transfer between native homes without changing reserve totals', () => {
|
|
const g = context(),
|
|
state = create(g);
|
|
const result = apply(g, state, [
|
|
change(home('emperor'), 'emperor', units(4)),
|
|
change(salusa, 'emperor', units(), units(4)),
|
|
change(salusa, 'emperor', units(0, 2)),
|
|
change(home('emperor'), 'emperor', units(), units(0, 2)),
|
|
]);
|
|
assert.deepEqual(result.g.players, g.players);
|
|
assert.deepEqual(
|
|
native(result.g, result.state, home('emperor'), 'emperor'),
|
|
units(11, 2),
|
|
);
|
|
assert.deepEqual(
|
|
native(result.g, result.state, salusa, 'emperor'),
|
|
units(4, 3),
|
|
);
|
|
assert.equal(result.receipts.length, 2);
|
|
assert.deepEqual(result.receipts[0], {
|
|
homeworld: home('emperor'),
|
|
player: 'emperor',
|
|
before: units(15),
|
|
after: units(11, 2),
|
|
});
|
|
});
|
|
|
|
void test('atomic mixed-home shipment withdraws the exact chosen types regardless of change order', () => {
|
|
const g = context(),
|
|
state = create(g);
|
|
const moved = apply(g, state, [
|
|
change(home('emperor'), 'emperor', units(4)),
|
|
change(salusa, 'emperor', units(), units(4)),
|
|
]);
|
|
const debits = [
|
|
change(home('emperor'), 'emperor', units(3)),
|
|
change(salusa, 'emperor', units(2, 4)),
|
|
];
|
|
const a = apply(moved.g, moved.state, debits),
|
|
b = apply(moved.g, moved.state, [...debits].reverse());
|
|
assert.deepEqual(a.state, b.state);
|
|
assert.deepEqual(a.g, b.g);
|
|
assert.equal(a.g.players[0].reserves, 11);
|
|
assert.equal(a.g.players[0].eliteReserves, 1);
|
|
assert.deepEqual(native(a.g, a.state, home('emperor'), 'emperor'), units(8));
|
|
assert.deepEqual(a.state.salusa, units(2, 1));
|
|
});
|
|
|
|
void test('foreign movement keeps visitors out of host reserves and own-native return credits the traveler once', () => {
|
|
let g = context(),
|
|
state = create(g);
|
|
let result = apply(g, state, [
|
|
change(home('atreides'), 'atreides', units(6)),
|
|
change(home('fremen'), 'atreides', units(), units(6)),
|
|
]);
|
|
g = result.g;
|
|
state = result.state;
|
|
assert.equal(g.players.find((p) => p.id === 'fremen')!.reserves, 20);
|
|
assert.equal(g.players.find((p) => p.id === 'atreides')!.reserves, 14);
|
|
result = apply(g, state, [
|
|
change(home('fremen'), 'atreides', units(6)),
|
|
change(salusa, 'atreides', units(), units(6)),
|
|
]);
|
|
assert.equal(physical(result.g, result.state, 'atreides'), 20);
|
|
assert.deepEqual(result.state.visitors, { [salusa]: { atreides: units(6) } });
|
|
result = apply(result.g, result.state, [
|
|
change(salusa, 'atreides', units(6)),
|
|
change(home('atreides'), 'atreides', units(), units(6)),
|
|
]);
|
|
assert.deepEqual(result.state.visitors, {});
|
|
assert.equal(result.g.players.find((p) => p.id === 'atreides')!.reserves, 20);
|
|
});
|
|
|
|
void test('revival deposits and native casualties use explicit home pools without authorizing the caller effect', () => {
|
|
const g = context();
|
|
g.players[0].reserves = 12;
|
|
g.players[0].eliteReserves = 2;
|
|
const state = create(g);
|
|
const revived = apply(g, state, [
|
|
change(home('emperor'), 'emperor', units(), units(3)),
|
|
change(salusa, 'emperor', units(), units(0, 2)),
|
|
]);
|
|
assert.equal(revived.g.players[0].reserves, 17);
|
|
assert.deepEqual(revived.state.salusa, units(0, 4));
|
|
const lost = apply(revived.g, revived.state, [
|
|
change(salusa, 'emperor', units(0, 3)),
|
|
]);
|
|
assert.equal(lost.g.players[0].reserves, 14);
|
|
assert.deepEqual(
|
|
native(lost.g, lost.state, home('emperor'), 'emperor'),
|
|
units(13),
|
|
);
|
|
reject(revived.g, revived.state, [
|
|
change(home('emperor'), 'emperor', units(0, 1)),
|
|
]);
|
|
});
|
|
|
|
void test('visitor casualties debit only the visiting group and retain native reserve totals', () => {
|
|
const g = context();
|
|
g.players[0].reserves = 16;
|
|
g.players[0].eliteReserves = 3;
|
|
const state = create(g);
|
|
state.visitors[home('atreides')] = { emperor: units(2, 2) };
|
|
const result = apply(g, state, [
|
|
change(home('atreides'), 'emperor', units(1, 2)),
|
|
]);
|
|
assert.deepEqual(result.g.players, g.players);
|
|
assert.deepEqual(result.state.visitors[home('atreides')].emperor, units(1));
|
|
assert.deepEqual(result.receipts[0].before, units(2, 2));
|
|
assert.deepEqual(result.receipts[0].after, units(1));
|
|
});
|
|
|
|
void test('typed Emperor visitors returning to Salusa credit that native pool and remain distinct from Kaitain', () => {
|
|
const g = context();
|
|
g.players[0].reserves = 16;
|
|
g.players[0].eliteReserves = 3;
|
|
const state = create(g);
|
|
state.visitors[home('atreides')] = { emperor: units(2, 2) };
|
|
const result = apply(g, state, [
|
|
change(home('atreides'), 'emperor', units(2, 2)),
|
|
change(salusa, 'emperor', units(), units(2, 2)),
|
|
]);
|
|
assert.deepEqual(result.state.visitors, {});
|
|
assert.deepEqual(result.state.salusa, units(2, 5));
|
|
assert.deepEqual(
|
|
native(result.g, result.state, home('emperor'), 'emperor'),
|
|
units(13),
|
|
);
|
|
assert.equal(result.g.players[0].reserves, 20);
|
|
assert.equal(physical(result.g, result.state, 'emperor'), 20);
|
|
});
|
|
|
|
void test('duplicate changes aggregate against the pretransaction pool and cannot spend an incoming deposit', () => {
|
|
const g = context(),
|
|
state = create(g);
|
|
const result = apply(g, state, [
|
|
change(home('atreides'), 'atreides', units(2)),
|
|
change(home('atreides'), 'atreides', units(3)),
|
|
]);
|
|
assert.equal(result.receipts.length, 1);
|
|
assert.deepEqual(result.receipts[0].after, units(15));
|
|
reject(g, state, [
|
|
change(home('atreides'), 'atreides', units(11)),
|
|
change(home('atreides'), 'atreides', units(10)),
|
|
]);
|
|
reject(g, state, [
|
|
change(home('fremen'), 'atreides', units(), units(1)),
|
|
change(home('fremen'), 'atreides', units(1)),
|
|
]);
|
|
});
|
|
|
|
void test('native totals and Salusa typed bounds reject wrong-pool withdrawals instead of clamping', () => {
|
|
const g = context(),
|
|
state = create(g);
|
|
reject(g, state, [change(home('emperor'), 'emperor', units(16))]);
|
|
reject(g, state, [change(home('emperor'), 'emperor', units(0, 1))]);
|
|
reject(g, state, [change(salusa, 'emperor', units(1))]);
|
|
for (const allocation of [
|
|
units(16),
|
|
units(0, 6),
|
|
units(-1),
|
|
units(1.5),
|
|
units(0, NaN),
|
|
]) {
|
|
const malformed = structuredClone(state);
|
|
malformed.salusa = allocation;
|
|
reject(g, malformed);
|
|
}
|
|
});
|
|
|
|
void test('printed special-counter caps include native and all foreign Homeworld pools', () => {
|
|
for (const faction of ['emperor', 'fremen', 'ixians'] as const) {
|
|
const g = context(true, [faction, 'atreides', 'ecaz']);
|
|
g.players[0].reserves = 10;
|
|
g.players[0].eliteReserves = cap(faction) - 1;
|
|
const state = create(g);
|
|
state.visitors[home('atreides')] = { [faction]: units(0, 1) };
|
|
assert.doesNotThrow(() => groups(g, state));
|
|
const duplicate = structuredClone(state);
|
|
duplicate.visitors[home('ecaz')] = { [faction]: units(0, 1) };
|
|
reject(g, duplicate);
|
|
reject(g, state, [change(home('ecaz'), faction, units(), units(0, 1))]);
|
|
const tooMany = structuredClone(g);
|
|
tooMany.players[0].eliteReserves = cap(faction) + 1;
|
|
assert.throws(() => create(tooMany), HomeworldCustodyError);
|
|
}
|
|
const g = context(),
|
|
state = create(g);
|
|
g.players[1].reserves = 19;
|
|
state.visitors[home('emperor')] = { atreides: units(0, 1) };
|
|
reject(g, state);
|
|
});
|
|
|
|
void test('malformed ledgers reject foreign native aliases, inactive homes, extra custody and invalid typed values', () => {
|
|
const g = context(),
|
|
state = create(g);
|
|
const malformed: State[] = [
|
|
{ ...state, visitors: { [home('atreides')]: { atreides: units(1) } } },
|
|
{ ...state, visitors: { [home('ixians')]: { atreides: units(1) } } },
|
|
{ ...state, visitors: { [home('atreides')]: { missing: units(1) } } },
|
|
{ ...state, visitors: { [home('atreides')]: { emperor: units(1) } } },
|
|
{ ...state, visitors: { [home('atreides')]: { emperor: units(-1) } } },
|
|
];
|
|
for (const value of malformed) reject(g, value);
|
|
for (const value of [NaN, Infinity, Number.MAX_SAFE_INTEGER, 0.5, -1])
|
|
reject(g, state, [change(home('atreides'), 'atreides', units(value))]);
|
|
reject(g, state, [change('arrakeen:10', 'atreides', units(1))]);
|
|
reject(g, state, [change(home('atreides'), 'missing', units(1))]);
|
|
const basic = context(false);
|
|
reject(basic, { ...create(basic), salusa: units() });
|
|
});
|
|
|
|
void test('invalid rosters and reserve records are rejected before location creation', () => {
|
|
const good = context();
|
|
const invalid: Context[] = [
|
|
{ ...good, players: [] },
|
|
{ ...good, players: good.players.slice(0, 1) },
|
|
{ ...good, players: [...good.players, good.players[0]] },
|
|
{
|
|
...good,
|
|
players: [
|
|
good.players[0],
|
|
{ ...good.players[1], id: good.players[0].id },
|
|
],
|
|
},
|
|
{
|
|
...good,
|
|
players: [good.players[0], { ...good.players[1], faction: 'emperor' }],
|
|
},
|
|
];
|
|
for (const field of ['reserves', 'eliteReserves'] as const)
|
|
for (const value of [-1, 0.5, NaN, 21]) {
|
|
const copy = structuredClone(good);
|
|
copy.players[0][field] = value;
|
|
invalid.push(copy);
|
|
}
|
|
for (const g of invalid)
|
|
assert.throws(() => locations(g), HomeworldCustodyError);
|
|
});
|
|
|
|
void test('create, read, receipts and quotes do not alias or inspect unrelated private information', () => {
|
|
const g = context(),
|
|
state = create(g);
|
|
for (const target of [g, ...g.players])
|
|
for (const key of [
|
|
'hand',
|
|
'traitors',
|
|
'prediction',
|
|
'spice',
|
|
'bot',
|
|
'ally',
|
|
])
|
|
Object.defineProperty(target, key, {
|
|
get() {
|
|
throw new Error(`Private ${key} was inspected`);
|
|
},
|
|
});
|
|
const read = groups(g, state);
|
|
read[0].forces.emperor.normal = 0;
|
|
assert.deepEqual(native(g, state, home('emperor'), 'emperor'), units(15));
|
|
const result = quote(g, state, [
|
|
change(home('emperor'), 'emperor', units(1)),
|
|
]);
|
|
result.receipts[0].after.normal = 0;
|
|
result.receipts[0].before.normal = 0;
|
|
result.players[0].reserves = 0;
|
|
result.state.salusa!.elite = 0;
|
|
assert.equal(g.players[0].reserves, 20);
|
|
assert.deepEqual(state.salusa, units(0, 5));
|
|
assert.doesNotThrow(() => create(g));
|
|
});
|
|
|
|
void test('prototype-like valid seat identifiers retain exact foreign custody rather than silently losing counters', () => {
|
|
const g = context(false, ['atreides', 'fremen']);
|
|
g.players[0].id = '__proto__';
|
|
const state = create(g);
|
|
const result = quote(g, state, [
|
|
change(home('atreides'), '__proto__', units(2)),
|
|
change(home('fremen'), '__proto__', units(), units(2)),
|
|
]);
|
|
assert.equal(
|
|
Object.hasOwn(result.state.visitors[home('fremen')], '__proto__'),
|
|
true,
|
|
);
|
|
assert.equal(
|
|
physical(
|
|
{ advanced: false, players: result.players },
|
|
result.state,
|
|
'__proto__',
|
|
),
|
|
20,
|
|
);
|
|
assert.deepEqual(JSON.parse(JSON.stringify(result.state)), result.state);
|
|
});
|