238 lines
6.6 KiB
TypeScript
238 lines
6.6 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { FACTIONS } from '../game/catalog';
|
|
import {
|
|
createNexusGuildSecretAlly,
|
|
validateNexusGuildSecretAlly,
|
|
quoteNexusGuildSecretShipment,
|
|
type NexusGuildSecretAllyContext,
|
|
type NexusGuildSecretAllyReceipt,
|
|
} from '../game/nexus-guild-secret-ally';
|
|
|
|
const context: NexusGuildSecretAllyContext = {
|
|
turn: 4,
|
|
players: [
|
|
{ id: 'a', faction: 'atreides' },
|
|
{ id: 'e', faction: 'emperor' },
|
|
{ id: 'f', faction: 'fremen' },
|
|
],
|
|
};
|
|
const clone = <T>(v: T): T => JSON.parse(JSON.stringify(v));
|
|
|
|
void test('Guild Secret Ally quotes all physical groups at Guild rates with one upward rounding and no force-count substitution', () => {
|
|
for (const type of [
|
|
'stronghold',
|
|
'sand',
|
|
'rock',
|
|
'polar',
|
|
'reserves',
|
|
'homeworld',
|
|
])
|
|
for (let amount = 1; amount <= 20; amount++)
|
|
assert.deepEqual(quoteNexusGuildSecretShipment(type, amount), {
|
|
physicalAmount: amount,
|
|
cost: ['stronghold', 'reserves', 'homeworld'].includes(type)
|
|
? Math.ceil(amount / 2)
|
|
: amount,
|
|
source: 'nexusGuildSecretAlly',
|
|
});
|
|
assert.equal(quoteNexusGuildSecretShipment('homeworld', 5).cost, 3);
|
|
assert.equal(
|
|
quoteNexusGuildSecretShipment('reserves', 5).cost,
|
|
3,
|
|
'return tariff is final, not halved a second time',
|
|
);
|
|
assert.equal(
|
|
quoteNexusGuildSecretShipment('sand', 5).cost,
|
|
5,
|
|
'paid Fremen cross-route must not inherit free native reinforcement',
|
|
);
|
|
});
|
|
|
|
void test('physical shipment quote rejects zero, fractional, excess, coerced or unknown destinations', () => {
|
|
for (const value of [
|
|
0,
|
|
-1,
|
|
21,
|
|
0.5,
|
|
NaN,
|
|
Infinity,
|
|
Number.MAX_SAFE_INTEGER,
|
|
'5',
|
|
null,
|
|
undefined,
|
|
])
|
|
assert.throws(() =>
|
|
quoteNexusGuildSecretShipment('stronghold', value as number),
|
|
);
|
|
for (const type of [
|
|
'',
|
|
'arrakeen',
|
|
'homeworld:atreides',
|
|
'space',
|
|
'Stronghold',
|
|
null,
|
|
undefined,
|
|
])
|
|
assert.throws(() => quoteNexusGuildSecretShipment(type as string, 5));
|
|
});
|
|
|
|
void test('each non-Guild native faction can own a canonically bound absent-Guild receipt without changing the supplied roster', () => {
|
|
for (const faction of FACTIONS.filter((f) => f.id !== 'guild')) {
|
|
const other = FACTIONS.filter(
|
|
(f) => f.id !== 'guild' && f.id !== faction.id,
|
|
).slice(0, 2);
|
|
const ctx = {
|
|
turn: 2,
|
|
players: [
|
|
{ id: 'owner', faction: faction.id },
|
|
...other.map((f, i) => ({ id: `other${i}`, faction: f.id })),
|
|
],
|
|
};
|
|
const before = clone(ctx),
|
|
receipt = createNexusGuildSecretAlly(ctx, 'owner');
|
|
assert.equal(
|
|
receipt.event,
|
|
JSON.stringify(['nexusGuildSecretAlly', 2, 'owner']),
|
|
);
|
|
assert.equal(receipt.phase, 5);
|
|
assert.equal(receipt.mode, 'secretAlly');
|
|
assert.deepEqual(ctx, before);
|
|
assert.notEqual(receipt.roster, ctx.players);
|
|
validateNexusGuildSecretAlly(ctx, clone(receipt));
|
|
assert.deepEqual(
|
|
createNexusGuildSecretAlly(
|
|
{ ...ctx, players: [...ctx.players].reverse() },
|
|
'owner',
|
|
),
|
|
receipt,
|
|
);
|
|
}
|
|
});
|
|
|
|
void test('seated Guild, unknown owner, malformed identities and duplicated seats or factions cannot issue receipts', () => {
|
|
const badRosters = [
|
|
[...context.players, { id: 'g', faction: 'guild' as const }],
|
|
[...context.players, context.players[0]],
|
|
context.players.map((p) => ({ ...p, id: 'same' })),
|
|
context.players.map((p) => ({ ...p, faction: 'fremen' as const })),
|
|
context.players.map((p) => ({
|
|
...p,
|
|
id: p.id === 'a' ? '__proto__' : p.id,
|
|
})),
|
|
[],
|
|
];
|
|
for (const players of badRosters) {
|
|
const ctx = { ...context, players },
|
|
before = clone(ctx);
|
|
assert.throws(() => createNexusGuildSecretAlly(ctx, 'a'));
|
|
assert.deepEqual(ctx, before);
|
|
}
|
|
assert.throws(() => createNexusGuildSecretAlly(context, 'outsider'));
|
|
for (const turn of [0, -1, 1.5, NaN, Infinity])
|
|
assert.throws(() => createNexusGuildSecretAlly({ ...context, turn }, 'a'));
|
|
});
|
|
|
|
void test('completed historical receipt survives later turns, alliance changes and private custody changes without reading them', () => {
|
|
const receipt = createNexusGuildSecretAlly(context, 'a');
|
|
const trap = () => {
|
|
throw new Error('private state read');
|
|
};
|
|
const historical = {
|
|
turn: 8,
|
|
players: context.players.map((p) =>
|
|
Object.freeze({
|
|
...p,
|
|
get hand() {
|
|
return trap();
|
|
},
|
|
get spice() {
|
|
return trap();
|
|
},
|
|
get reserves() {
|
|
return trap();
|
|
},
|
|
get forces() {
|
|
return trap();
|
|
},
|
|
get shipped() {
|
|
return trap();
|
|
},
|
|
get ally() {
|
|
return trap();
|
|
},
|
|
}),
|
|
),
|
|
};
|
|
validateNexusGuildSecretAlly(historical, receipt);
|
|
assert.deepEqual(
|
|
createNexusGuildSecretAlly({ ...historical, turn: 4 }, 'a'),
|
|
receipt,
|
|
);
|
|
assert.throws(() =>
|
|
validateNexusGuildSecretAlly({ ...historical, turn: 3 }, receipt),
|
|
);
|
|
assert.throws(() =>
|
|
validateNexusGuildSecretAlly(
|
|
{ ...historical, players: historical.players.slice(0, 2) },
|
|
receipt,
|
|
),
|
|
);
|
|
});
|
|
|
|
void test('exact owner, event, roster, phase and signature corruption rejects immutably after JSON recovery', () => {
|
|
const receipt = createNexusGuildSecretAlly(context, 'a');
|
|
const changes: ((r: NexusGuildSecretAllyReceipt) => void)[] = [
|
|
(r) => {
|
|
r.owner = 'e';
|
|
},
|
|
(r) => {
|
|
r.turn = 3;
|
|
},
|
|
(r) => {
|
|
r.event = 'old';
|
|
},
|
|
(r) => {
|
|
r.signature = '';
|
|
},
|
|
(r) => {
|
|
Object.assign(r, { phase: 4 });
|
|
},
|
|
(r) => {
|
|
Object.assign(r, { mode: 'cunning' });
|
|
},
|
|
(r) => {
|
|
Object.assign(r, { version: 2 });
|
|
},
|
|
(r) => {
|
|
r.roster.reverse();
|
|
},
|
|
(r) => {
|
|
r.roster[0].id = 'foreign';
|
|
},
|
|
(r) => {
|
|
r.roster[0].faction = 'guild';
|
|
},
|
|
(r) => {
|
|
delete (r as Partial<NexusGuildSecretAllyReceipt>).signature;
|
|
},
|
|
(r) => {
|
|
Object.assign(r, { cost: 0 });
|
|
},
|
|
];
|
|
for (const change of changes) {
|
|
const bad = clone(receipt);
|
|
change(bad);
|
|
const before = clone(bad);
|
|
assert.throws(() => validateNexusGuildSecretAlly(context, bad));
|
|
assert.deepEqual(bad, before);
|
|
}
|
|
const inherited = Object.assign(
|
|
Object.create({ untrusted: true }),
|
|
receipt,
|
|
) as NexusGuildSecretAllyReceipt;
|
|
assert.throws(() => validateNexusGuildSecretAlly(context, inherited));
|
|
assert.equal(JSON.stringify(receipt).includes('hand'), false);
|
|
assert.equal(JSON.stringify(receipt).includes('spice'), false);
|
|
});
|