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 { 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) .signature; }, (s) => { delete (s as Partial).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); });