424 lines
13 KiB
TypeScript
424 lines
13 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('Advanced may return an actual foreign garrison to either native Emperor World at the normal rate', () => {
|
|
const { context, custody } = fixture();
|
|
for (const destination of ['homeworld:emperor', 'homeworld:emperor:salusa']) {
|
|
const before = structuredClone({ context, custody });
|
|
const result = quote(context, custody, {
|
|
player: 'e', destination,
|
|
sources: { 'homeworld:atreides': { normal: 4, elite: 1 } },
|
|
});
|
|
assert.equal(result.amount, 5);
|
|
assert.equal(result.cost, 5);
|
|
assert.equal(result.players[0].reserves, 20);
|
|
assert.equal(result.players[0].eliteReserves, 5);
|
|
assert.equal(result.state.visitors['homeworld:atreides'], undefined);
|
|
const next = { ...context, players: result.players.map(p => ({ ...p, ally: null })) };
|
|
assert.deepEqual(totals(next, result.state), totals(context, custody));
|
|
assert.deepEqual({ context, custody }, before);
|
|
}
|
|
});
|
|
|
|
void test('Basic preserves the native return restriction and all modes reject a mutual ally destination', () => {
|
|
const { context, custody } = fixture();
|
|
const basic = { ...context, advanced: false };
|
|
const basicCustody = { ...custody, salusa: null };
|
|
rejected(basic, basicCustody, {
|
|
...invasion,
|
|
destination: 'homeworld:emperor',
|
|
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);
|
|
});
|