311 lines
8.9 KiB
TypeScript
311 lines
8.9 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import {
|
|
appendTupileIntelligenceObservation as append,
|
|
createTupileIntelligenceState as create,
|
|
validateTupileIntelligenceState as validate,
|
|
tupileIntelligenceUsedFactions as used,
|
|
tupileIntelligenceObservationSignature,
|
|
tupileIntelligenceStateSignature,
|
|
type TupileIntelligenceObservation,
|
|
type TupileIntelligenceState,
|
|
} from '../game/tupile-intelligence-state';
|
|
import type { FactionId } from '../game/catalog';
|
|
|
|
const players = [
|
|
{ id: 'c', faction: 'choam' as const },
|
|
{ id: 'e', faction: 'emperor' as const },
|
|
{ id: 'a', faction: 'atreides' as const },
|
|
];
|
|
function observation(): Omit<TupileIntelligenceObservation, 'signature'> {
|
|
return {
|
|
event: 'first-query',
|
|
owner: 'c',
|
|
target: 'e',
|
|
faction: 'emperor',
|
|
category: 'weapons',
|
|
spice: 17,
|
|
count: 2,
|
|
turn: 2,
|
|
phase: 6,
|
|
contact: [
|
|
'homeworld:emperor',
|
|
'homeworld:emperor:salusa',
|
|
'homeworld:choam',
|
|
],
|
|
};
|
|
}
|
|
function two() {
|
|
return append(append(create('c'), observation()), {
|
|
...observation(),
|
|
event: 'second-query',
|
|
target: 'a',
|
|
faction: 'atreides',
|
|
category: 'defenses',
|
|
turn: 3,
|
|
phase: 0,
|
|
spice: 0,
|
|
count: 0,
|
|
contact: ['homeworld:atreides'],
|
|
});
|
|
}
|
|
|
|
void test('new owner ledger and detached observations preserve a historical private answer through JSON and later turns', () => {
|
|
const empty = create('c');
|
|
validate(empty, players, 1);
|
|
assert.deepEqual(used(empty), []);
|
|
const input = observation();
|
|
const state = append(empty, input);
|
|
assert.equal(empty.receipts.length, 0);
|
|
assert.deepEqual(used(state), ['emperor']);
|
|
validate(
|
|
JSON.parse(JSON.stringify(state)) as TupileIntelligenceState,
|
|
players,
|
|
10,
|
|
);
|
|
input.spice = 0;
|
|
input.count = 0;
|
|
input.contact.pop();
|
|
assert.equal(state.receipts[0].spice, 17);
|
|
assert.equal(state.receipts[0].count, 2);
|
|
assert.equal(state.receipts[0].contact.length, 3);
|
|
assert.deepEqual(Object.keys(state).sort(), [
|
|
'owner',
|
|
'receipts',
|
|
'signature',
|
|
'version',
|
|
]);
|
|
});
|
|
|
|
void test('faction lifetime use never resets with another turn, phase, category, contact direction or target ID', () => {
|
|
const state = append(create('c'), observation());
|
|
const before = JSON.stringify(state);
|
|
for (const change of [
|
|
{ event: 'new-query' },
|
|
{ event: 'new-query', turn: 7, phase: 0 },
|
|
{ event: 'new-query', category: 'defenses' as const },
|
|
{ event: 'new-query', contact: ['homeworld:choam'] },
|
|
{ event: 'new-query', contact: ['homeworld:emperor:salusa'] },
|
|
{ event: 'new-query', target: 'new-emperor-seat' },
|
|
])
|
|
assert.throws(() => append(state, { ...observation(), ...change }));
|
|
assert.throws(() =>
|
|
append(state, {
|
|
...observation(),
|
|
target: 'a',
|
|
faction: 'atreides',
|
|
contact: ['homeworld:atreides'],
|
|
}),
|
|
);
|
|
assert.equal(JSON.stringify(state), before);
|
|
assert.deepEqual(used(two()), ['emperor', 'atreides']);
|
|
});
|
|
|
|
void test('only physical owner/target Homeworld IDs supply historical provenance, with both Emperor worlds allowed', () => {
|
|
for (const contact of [
|
|
['homeworld:choam'],
|
|
['homeworld:emperor'],
|
|
['homeworld:emperor:salusa'],
|
|
])
|
|
validate(append(create('c'), { ...observation(), contact }), players, 2);
|
|
for (const contact of [
|
|
[],
|
|
['homeworld:emperor', 'homeworld:emperor'],
|
|
['kaitain'],
|
|
['salusa_secundus'],
|
|
['homeworld:atreides'],
|
|
['arrakeen'],
|
|
['homeworld:choam:salusa'],
|
|
])
|
|
assert.throws(() => append(create('c'), { ...observation(), contact }));
|
|
assert.throws(() =>
|
|
append(create('c'), {
|
|
...observation(),
|
|
target: 'a',
|
|
faction: 'atreides',
|
|
contact: ['homeworld:emperor:salusa'],
|
|
}),
|
|
);
|
|
});
|
|
|
|
void test('every signed observation field and the ledger order, version and receipt membership resist corruption', () => {
|
|
const original = two();
|
|
const mutations: ((state: TupileIntelligenceState) => void)[] = [
|
|
(s) => {
|
|
s.owner = 'a';
|
|
},
|
|
(s) => {
|
|
s.version = 0 as 1;
|
|
},
|
|
(s) => {
|
|
s.receipts.pop();
|
|
},
|
|
(s) => {
|
|
s.receipts.reverse();
|
|
},
|
|
(s) => {
|
|
s.receipts[0].event = 'changed';
|
|
},
|
|
(s) => {
|
|
s.receipts[0].owner = 'a';
|
|
},
|
|
(s) => {
|
|
s.receipts[0].target = 'a';
|
|
},
|
|
(s) => {
|
|
s.receipts[0].faction = 'atreides';
|
|
},
|
|
(s) => {
|
|
s.receipts[0].category = 'defenses';
|
|
},
|
|
(s) => {
|
|
s.receipts[0].spice++;
|
|
},
|
|
(s) => {
|
|
s.receipts[0].count++;
|
|
},
|
|
(s) => {
|
|
s.receipts[0].turn++;
|
|
},
|
|
(s) => {
|
|
s.receipts[0].phase++;
|
|
},
|
|
(s) => {
|
|
s.receipts[0].contact.reverse();
|
|
},
|
|
(s) => {
|
|
delete (s.receipts[0] as Partial<TupileIntelligenceObservation>)
|
|
.signature;
|
|
},
|
|
(s) => {
|
|
delete (s as Partial<TupileIntelligenceState>).signature;
|
|
},
|
|
];
|
|
for (const mutate of mutations) {
|
|
const state = structuredClone(original);
|
|
mutate(state);
|
|
const before = JSON.stringify(state);
|
|
assert.throws(() => validate(state, players, 5));
|
|
assert.throws(() => used(state));
|
|
assert.equal(JSON.stringify(state), before);
|
|
}
|
|
// Object key order is not observation order and survives ordinary JSON tooling.
|
|
const reordered = JSON.parse(
|
|
JSON.stringify(original),
|
|
(_key, value: unknown) =>
|
|
value && typeof value === 'object' && !Array.isArray(value)
|
|
? Object.fromEntries(Object.entries(value).reverse())
|
|
: value,
|
|
) as TupileIntelligenceState;
|
|
validate(reordered, players, 5);
|
|
});
|
|
|
|
void test('valid signatures cannot disguise invalid participants, duplicate faction use or a future observation', () => {
|
|
const state = append(create('c'), observation());
|
|
for (const seats of [
|
|
players.filter((p) => p.id !== 'e'),
|
|
players.map((p) =>
|
|
p.id === 'e' ? { ...p, faction: 'guild' as const } : p,
|
|
),
|
|
players.map((p) =>
|
|
p.id === 'c' ? { ...p, faction: 'guild' as const } : p,
|
|
),
|
|
[...players, { id: 'replacement', faction: 'emperor' as const }],
|
|
[...players, { id: 'a', faction: 'guild' as const }],
|
|
])
|
|
assert.throws(() => validate(state, seats, 2));
|
|
assert.throws(() => validate(state, players, 1));
|
|
for (const turn of [-1, 0, 1.5, Infinity])
|
|
assert.throws(() => validate(state, players, turn));
|
|
const duplicate = structuredClone(state);
|
|
const extra = {
|
|
...duplicate.receipts[0],
|
|
event: 'new-event',
|
|
target: 'new-target',
|
|
};
|
|
extra.signature = tupileIntelligenceObservationSignature(extra);
|
|
duplicate.receipts.push(extra);
|
|
duplicate.signature = tupileIntelligenceStateSignature(duplicate);
|
|
assert.throws(() => validate(duplicate, players, 2));
|
|
});
|
|
|
|
void test('malformed observations reject before append and never preserve a hidden hand or partial disclosure', () => {
|
|
const state = create('c');
|
|
const before = JSON.stringify(state);
|
|
for (const patch of [
|
|
{ event: '' },
|
|
{ owner: 'other' },
|
|
{ target: 'c' },
|
|
{ faction: 'choam' },
|
|
{ faction: 'invented' },
|
|
{ category: 'both' },
|
|
{ spice: -1 },
|
|
{ spice: NaN },
|
|
{ spice: Number.MAX_SAFE_INTEGER + 1 },
|
|
{ count: -1 },
|
|
{ count: 0.5 },
|
|
{ turn: 0 },
|
|
{ phase: 9 },
|
|
{ phase: -1 },
|
|
{ signature: 'wrong' },
|
|
{ hand: ['private-card'] },
|
|
]) {
|
|
assert.throws(() =>
|
|
append(state, { ...observation(), ...patch } as Parameters<
|
|
typeof append
|
|
>[1]),
|
|
);
|
|
assert.equal(JSON.stringify(state), before);
|
|
}
|
|
const maximum = append(state, {
|
|
...observation(),
|
|
spice: Number.MAX_SAFE_INTEGER,
|
|
count: Number.MAX_SAFE_INTEGER,
|
|
});
|
|
validate(maximum, players, 2);
|
|
assert.throws(() => create(' '));
|
|
assert.throws(() =>
|
|
validate(null as unknown as TupileIntelligenceState, players, 2),
|
|
);
|
|
});
|
|
|
|
void test('public validation reads no private fields and immutable inputs or returned use lists cannot alter receipts', () => {
|
|
const state = two();
|
|
const seats: { id: string; faction: FactionId }[] = structuredClone(players);
|
|
for (const seat of seats)
|
|
for (const field of [
|
|
'spice',
|
|
'hand',
|
|
'forces',
|
|
'reserves',
|
|
'traitors',
|
|
'battlePlan',
|
|
])
|
|
Object.defineProperty(seat, field, {
|
|
get() {
|
|
throw new Error(`Private ${field}`);
|
|
},
|
|
});
|
|
for (const receipt of state.receipts) {
|
|
Object.freeze(receipt.contact);
|
|
Object.freeze(receipt);
|
|
}
|
|
Object.freeze(state.receipts);
|
|
Object.freeze(state);
|
|
validate(state, seats, 4);
|
|
const factions = used(state);
|
|
factions.pop();
|
|
assert.deepEqual(used(state), ['emperor', 'atreides']);
|
|
const next = append(state, {
|
|
...observation(),
|
|
event: 'third',
|
|
target: 'g',
|
|
faction: 'guild',
|
|
contact: ['homeworld:guild'],
|
|
});
|
|
assert.notEqual(next.receipts[0], state.receipts[0]);
|
|
assert.notEqual(next.receipts[0].contact, state.receipts[0].contact);
|
|
assert.equal(state.receipts.length, 2);
|
|
validate(next, [...seats, { id: 'g', faction: 'guild' }], 4);
|
|
});
|