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.
404 lines
12 KiB
TypeScript
404 lines
12 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import {
|
|
quoteHomeworldShipment as quote,
|
|
type HomeworldShipmentContext,
|
|
type HomeworldShipmentIntent,
|
|
} from '../game/homeworld-shipment';
|
|
import {
|
|
HomeworldCustodyError,
|
|
homeworldForceGroups,
|
|
type HomeworldCustody,
|
|
} from '../game/homeworld-custody';
|
|
|
|
function fixture(): {
|
|
context: HomeworldShipmentContext;
|
|
custody: HomeworldCustody;
|
|
} {
|
|
return {
|
|
context: {
|
|
advanced: true,
|
|
players: [
|
|
{
|
|
id: 'e',
|
|
faction: 'emperor',
|
|
reserves: 15,
|
|
eliteReserves: 4,
|
|
ally: null,
|
|
},
|
|
{
|
|
id: 'a',
|
|
faction: 'atreides',
|
|
reserves: 17,
|
|
eliteReserves: 0,
|
|
ally: null,
|
|
},
|
|
{
|
|
id: 'g',
|
|
faction: 'guild',
|
|
reserves: 20,
|
|
eliteReserves: 0,
|
|
ally: null,
|
|
},
|
|
{
|
|
id: 'f',
|
|
faction: 'fremen',
|
|
reserves: 20,
|
|
eliteReserves: 3,
|
|
ally: null,
|
|
},
|
|
],
|
|
},
|
|
custody: {
|
|
salusa: { normal: 2, elite: 3 },
|
|
visitors: {
|
|
'homeworld:atreides': { e: { normal: 4, elite: 1 } },
|
|
'homeworld:emperor': { a: { normal: 3, elite: 0 } },
|
|
},
|
|
},
|
|
};
|
|
}
|
|
const invasion: HomeworldShipmentIntent = {
|
|
player: 'e',
|
|
destination: 'homeworld:guild',
|
|
sources: { 'homeworld:emperor': { normal: 3, elite: 1 } },
|
|
};
|
|
function rejected(
|
|
context: HomeworldShipmentContext,
|
|
custody: HomeworldCustody,
|
|
intent: unknown = invasion,
|
|
) {
|
|
const before = structuredClone({ context, custody, intent });
|
|
assert.throws(
|
|
() => quote(context, custody, intent as HomeworldShipmentIntent),
|
|
HomeworldCustodyError,
|
|
);
|
|
assert.deepEqual({ context, custody, intent }, before);
|
|
}
|
|
function totals(context: HomeworldShipmentContext, custody: HomeworldCustody) {
|
|
const groups = homeworldForceGroups(context, custody);
|
|
return context.players.map((p) => ({
|
|
player: p.id,
|
|
normal: groups.reduce((n, w) => n + (w.forces[p.id]?.normal ?? 0), 0),
|
|
elite: groups.reduce((n, w) => n + (w.forces[p.id]?.elite ?? 0), 0),
|
|
}));
|
|
}
|
|
|
|
void test('native invasion preserves typed global custody and yields detached source and destination receipts', () => {
|
|
const { context, custody } = fixture();
|
|
const before = structuredClone({ context, custody, invasion });
|
|
const result = quote(context, custody, invasion);
|
|
assert.equal(result.amount, 4);
|
|
assert.equal(result.elite, 1);
|
|
assert.equal(result.cost, 4);
|
|
assert.deepEqual(result.sources, [
|
|
{
|
|
homeworld: 'homeworld:emperor',
|
|
player: 'e',
|
|
before: { normal: 9, elite: 1 },
|
|
after: { normal: 6, elite: 0 },
|
|
},
|
|
]);
|
|
assert.equal(result.receipts.length, 2);
|
|
assert.deepEqual(result.state.visitors['homeworld:guild'].e, {
|
|
normal: 3,
|
|
elite: 1,
|
|
});
|
|
assert.deepEqual(result.state.salusa, custody.salusa);
|
|
assert.equal(result.players[0].reserves, 11);
|
|
assert.equal(result.players[0].eliteReserves, 3);
|
|
const next = {
|
|
...context,
|
|
players: result.players.map((p) => ({ ...p, ally: null })),
|
|
};
|
|
assert.deepEqual(totals(next, result.state), totals(context, custody));
|
|
assert.deepEqual({ context, custody, invasion }, before);
|
|
result.sources[0].before.normal = 0;
|
|
result.state.salusa!.normal = 0;
|
|
result.state.visitors['homeworld:atreides'].e.normal = 0;
|
|
result.players[0].reserves = 0;
|
|
assert.deepEqual({ context, custody, invasion }, before);
|
|
});
|
|
|
|
void test('Guild may return an exact foreign garrison to its native world at the rounded half rate', () => {
|
|
const { context, custody } = fixture();
|
|
const outward = quote(context, custody, {
|
|
player: 'g',
|
|
destination: 'homeworld:atreides',
|
|
sources: { 'homeworld:guild': { normal: 5, elite: 0 } },
|
|
});
|
|
const next = {
|
|
...context,
|
|
players: outward.players.map((p) => ({ ...p, ally: null })),
|
|
};
|
|
const before = structuredClone({ next, custody: outward.state });
|
|
const returned = quote(next, outward.state, {
|
|
player: 'g',
|
|
destination: 'homeworld:guild',
|
|
sources: { 'homeworld:atreides': { normal: 5, elite: 0 } },
|
|
});
|
|
assert.equal(returned.amount, 5);
|
|
assert.equal(returned.cost, 3);
|
|
assert.equal(returned.players[2].reserves, 20);
|
|
assert.deepEqual(returned.state, custody);
|
|
assert.deepEqual(returned.sources[0], {
|
|
homeworld: 'homeworld:atreides',
|
|
player: 'g',
|
|
before: { normal: 5, elite: 0 },
|
|
after: { normal: 0, elite: 0 },
|
|
});
|
|
assert.deepEqual({ next, custody: outward.state }, before);
|
|
rejected(context, custody, {
|
|
player: 'g',
|
|
destination: 'homeworld:guild',
|
|
sources: { 'homeworld:guild': { normal: 1, elite: 0 } },
|
|
});
|
|
});
|
|
|
|
void test('Guild rounds the half rate once for odd and even counts including one and twenty', () => {
|
|
for (const amount of [1, 2, 3, 4, 5, 19, 20]) {
|
|
const { context, custody } = fixture();
|
|
const result = quote(context, custody, {
|
|
player: 'g',
|
|
destination: 'homeworld:atreides',
|
|
sources: { 'homeworld:guild': { normal: amount, elite: 0 } },
|
|
});
|
|
assert.equal(result.cost, Math.ceil(amount / 2));
|
|
assert.equal(result.amount, amount);
|
|
}
|
|
});
|
|
|
|
void test('Fremen invasion pays for normal and special counters; Guild alliance does not grant invasion discounts', () => {
|
|
const { context, custody } = fixture();
|
|
context.players[2].ally = 'f';
|
|
context.players[3].ally = 'g';
|
|
const result = quote(context, custody, {
|
|
player: 'f',
|
|
destination: 'homeworld:atreides',
|
|
sources: { 'homeworld:fremen': { normal: 2, elite: 3 } },
|
|
});
|
|
assert.equal(result.cost, 5);
|
|
assert.equal(result.amount, 5);
|
|
assert.equal(result.elite, 3);
|
|
assert.equal(result.players[3].reserves, 15);
|
|
assert.equal(result.players[3].eliteReserves, 0);
|
|
});
|
|
|
|
void test('foreign source departure moves only that explicit garrison and survives JSON restoration', () => {
|
|
const { context, custody } = fixture();
|
|
const result = quote(context, custody, {
|
|
player: 'e',
|
|
destination: 'homeworld:guild',
|
|
sources: { 'homeworld:atreides': { normal: 4, elite: 1 } },
|
|
});
|
|
assert.equal(result.cost, 5);
|
|
assert.equal(result.state.visitors['homeworld:atreides'], undefined);
|
|
assert.deepEqual(
|
|
result.players,
|
|
context.players.map(({ ally: _ally, ...p }) => p),
|
|
);
|
|
const restored = JSON.parse(JSON.stringify(result)) as typeof result;
|
|
const second = quote(
|
|
{
|
|
...context,
|
|
players: restored.players.map((p) => ({ ...p, ally: null })),
|
|
},
|
|
restored.state,
|
|
{
|
|
player: 'e',
|
|
destination: 'homeworld:atreides',
|
|
sources: { 'homeworld:guild': { normal: 4, elite: 1 } },
|
|
},
|
|
);
|
|
assert.deepEqual(second.state, custody);
|
|
assert.deepEqual(second.players, result.players);
|
|
rejected(context, custody, {
|
|
player: 'e',
|
|
destination: 'homeworld:guild',
|
|
sources: { 'homeworld:atreides': { normal: 5, elite: 0 } },
|
|
});
|
|
});
|
|
|
|
void test('Advanced Emperor may combine its two native origins and retains precise Salusa allocation', () => {
|
|
const { context, custody } = fixture();
|
|
const result = quote(context, custody, {
|
|
...invasion,
|
|
sources: {
|
|
'homeworld:emperor': { normal: 3, elite: 1 },
|
|
'homeworld:emperor:salusa': { normal: 2, elite: 2 },
|
|
},
|
|
});
|
|
assert.equal(result.amount, 8);
|
|
assert.equal(result.elite, 3);
|
|
assert.equal(result.cost, 8);
|
|
assert.equal(result.sources.length, 2);
|
|
assert.deepEqual(result.state.salusa, { normal: 0, elite: 1 });
|
|
assert.deepEqual(result.state.visitors['homeworld:guild'].e, {
|
|
normal: 5,
|
|
elite: 3,
|
|
});
|
|
assert.equal(result.players[0].reserves, 7);
|
|
assert.equal(result.players[0].eliteReserves, 1);
|
|
assert.deepEqual(
|
|
result.sources.map((r) => r.before),
|
|
[
|
|
{ normal: 9, elite: 1 },
|
|
{ normal: 2, elite: 3 },
|
|
],
|
|
);
|
|
});
|
|
|
|
void test('own and mutual ally destinations are rejected, including either Emperor world', () => {
|
|
const { context, custody } = fixture();
|
|
for (const destination of ['homeworld:emperor', 'homeworld:emperor:salusa'])
|
|
rejected(context, custody, {
|
|
...invasion,
|
|
destination,
|
|
sources: { 'homeworld:atreides': { normal: 1, elite: 0 } },
|
|
});
|
|
context.players[0].ally = 'a';
|
|
context.players[1].ally = 'e';
|
|
rejected(context, custody, {
|
|
...invasion,
|
|
destination: 'homeworld:atreides',
|
|
});
|
|
for (const destination of ['homeworld:emperor', 'homeworld:emperor:salusa'])
|
|
rejected(context, custody, {
|
|
player: 'a',
|
|
destination,
|
|
sources: { 'homeworld:atreides': { normal: 1, elite: 0 } },
|
|
});
|
|
});
|
|
|
|
void test('unrelated multi-source shipments, repeated source destinations and inactive worlds cannot bypass custody', () => {
|
|
const { context, custody } = fixture();
|
|
for (const sources of [
|
|
{},
|
|
{
|
|
'homeworld:emperor': { normal: 1, elite: 0 },
|
|
'homeworld:atreides': { normal: 1, elite: 0 },
|
|
},
|
|
{
|
|
'homeworld:emperor': { normal: 1, elite: 0 },
|
|
'homeworld:emperor:salusa': { normal: 1, elite: 0 },
|
|
'homeworld:atreides': { normal: 1, elite: 0 },
|
|
},
|
|
{ 'homeworld:guild': { normal: 1, elite: 0 } },
|
|
{ 'homeworld:harkonnen': { normal: 1, elite: 0 } },
|
|
{ arrakeen: { normal: 1, elite: 0 } },
|
|
])
|
|
rejected(context, custody, { ...invasion, sources });
|
|
rejected(context, custody, {
|
|
...invasion,
|
|
player: 'a',
|
|
sources: {
|
|
'homeworld:emperor': { normal: 1, elite: 0 },
|
|
'homeworld:emperor:salusa': { normal: 1, elite: 0 },
|
|
},
|
|
});
|
|
const basic = { ...context, advanced: false };
|
|
rejected(
|
|
basic,
|
|
{ ...custody, salusa: null },
|
|
{
|
|
...invasion,
|
|
sources: {
|
|
'homeworld:emperor': { normal: 1, elite: 0 },
|
|
'homeworld:emperor:salusa': { normal: 1, elite: 0 },
|
|
},
|
|
},
|
|
);
|
|
assert.equal(quote(basic, { ...custody, salusa: null }, invasion).amount, 4);
|
|
});
|
|
|
|
void test('malformed and unavailable typed orders reject immutably without pooling or substituting counters', () => {
|
|
const { context, custody } = fixture();
|
|
for (const intent of [
|
|
null,
|
|
[],
|
|
{},
|
|
{ ...invasion, payment: 0 },
|
|
{ ...invasion, player: 'absent' },
|
|
{ ...invasion, destination: 'arrakeen' },
|
|
{ ...invasion, sources: [] },
|
|
...[
|
|
null,
|
|
{},
|
|
{ normal: 0, elite: 0 },
|
|
{ normal: 1 },
|
|
{ normal: 1, elite: 0, advisors: true },
|
|
{ normal: '1', 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: 10, elite: 0 },
|
|
{ normal: 0, elite: 2 },
|
|
].map((forces) => ({
|
|
...invasion,
|
|
sources: { 'homeworld:emperor': forces },
|
|
})),
|
|
{
|
|
...invasion,
|
|
sources: {
|
|
'homeworld:emperor': { normal: 19, elite: 0 },
|
|
'homeworld:emperor:salusa': { normal: 1, elite: 1 },
|
|
},
|
|
},
|
|
])
|
|
rejected(context, custody, intent);
|
|
});
|
|
|
|
void test('entire custody is validated before transport, including duplicate reserves and foreign physical totals', () => {
|
|
for (const mutate of [
|
|
(c: HomeworldShipmentContext) => {
|
|
c.players.push({ ...c.players[0] });
|
|
},
|
|
(c: HomeworldShipmentContext) => {
|
|
c.players[0].reserves += 1;
|
|
},
|
|
(c: HomeworldShipmentContext) => {
|
|
c.players[0].ally = 'absent';
|
|
},
|
|
(_c: HomeworldShipmentContext, s: HomeworldCustody) => {
|
|
s.salusa!.elite = 5;
|
|
},
|
|
(_c: HomeworldShipmentContext, s: HomeworldCustody) => {
|
|
s.visitors['homeworld:atreides'].e.elite = 2;
|
|
},
|
|
(_c: HomeworldShipmentContext, s: HomeworldCustody) => {
|
|
s.visitors['homeworld:atreides'].a = { normal: 1, elite: 0 };
|
|
},
|
|
(_c: HomeworldShipmentContext, s: HomeworldCustody) => {
|
|
s.visitors['homeworld:harkonnen'] = { g: { normal: 1, elite: 0 } };
|
|
},
|
|
]) {
|
|
const { context, custody } = fixture();
|
|
mutate(context, custody);
|
|
rejected(context, custody);
|
|
}
|
|
});
|
|
|
|
void test('quote reads no secret cards, spice, RNG or transport usage and commits no payment', () => {
|
|
const { context, custody } = fixture();
|
|
const expected = quote(context, custody, invasion);
|
|
for (const object of [context, ...context.players])
|
|
for (const key of [
|
|
'hand',
|
|
'spice',
|
|
'rng',
|
|
'shipped',
|
|
'payment',
|
|
'response',
|
|
])
|
|
Object.defineProperty(object, key, {
|
|
enumerable: true,
|
|
get() {
|
|
throw new Error('private or caller-owned context read');
|
|
},
|
|
});
|
|
assert.deepEqual(quote(context, custody, invasion), expected);
|
|
});
|