Files
Dune/tests/ecaz-spice-allocation.test.ts
2026-09-08 07:37:52 +10:00

262 lines
8.4 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import {
createSpiceAllocation as create,
quoteSpiceAllocation as quote,
SpiceAllocationError,
type AllocationState,
type SharedSpiceLot,
type SpiceAllocationAction,
} from '../game/ecaz-spice-allocation';
const lot = (amount = 7, territory = 'hagga_basin'): SharedSpiceLot => ({
territory,
ecaz: 'e',
ally: 'a',
amount,
});
const reload = (state: AllocationState): AllocationState =>
JSON.parse(JSON.stringify(state));
function rejected(
state: AllocationState,
actor: string,
action: SpiceAllocationAction,
) {
const before = structuredClone(state);
assert.throws(() => quote(state, actor, action), SpiceAllocationError);
assert.deepEqual(state, before);
}
void test('creation skips zero lots, preserves positive order and detaches the initial protocol', () => {
const input = [lot(0, 'the_great_flat'), lot(7), lot(2, 'imperial_basin')];
const state = create(input)!;
assert.deepEqual(state, {
lots: input.slice(1),
index: 0,
offer: null,
player: 'e',
});
input[1].amount = 99;
assert.equal(state.lots[0].amount, 7);
assert.equal(create([]), null);
assert.equal(create([lot(0)]), null);
});
void test('a proposal transfers the decision but produces no allocation until the other party accepts', () => {
const state = create([lot()])!;
const before = reload(state);
const proposed = quote(state, 'e', { kind: 'propose', ecazShare: 5 });
assert.equal(proposed.receipt, null);
assert.equal(proposed.next!.player, 'a');
assert.deepEqual(proposed.next!.offer, { by: 'e', ecazShare: 5 });
assert.deepEqual(state, before);
assert.deepEqual(quote(reload(proposed.next!), 'a', { kind: 'accept' }), {
next: null,
receipt: {
territory: 'hagga_basin',
ecaz: 'e',
ally: 'a',
ecazAmount: 5,
allyAmount: 2,
method: 'agreement',
},
});
});
void test('counteroffers alternate parties and agreement uses the latest share', () => {
let state = create([lot(9)])!;
state = quote(state, 'e', { kind: 'propose', ecazShare: 8 }).next!;
state = quote(reload(state), 'a', { kind: 'propose', ecazShare: 2 }).next!;
assert.equal(state.player, 'e');
assert.deepEqual(state.offer, { by: 'a', ecazShare: 2 });
state = quote(state, 'e', { kind: 'propose', ecazShare: 4 }).next!;
const receipt = quote(reload(state), 'a', { kind: 'accept' }).receipt!;
assert.equal(receipt.ecazAmount, 4);
assert.equal(receipt.allyAmount, 5);
});
void test('equal fallback is available on either current turn and sends the odd remainder to the ally', () => {
for (const amount of [1, 2, 7, 8, Number.MAX_SAFE_INTEGER]) {
const initial = create([lot(amount)])!;
const states = [
initial,
quote(initial, 'e', { kind: 'propose', ecazShare: 0 }).next!,
];
for (const state of states) {
const result = quote(state, state.player, { kind: 'equal' });
assert.equal(result.next, null);
assert.equal(result.receipt!.method, 'equal');
assert.equal(result.receipt!.ecazAmount, Math.floor(amount / 2));
assert.equal(result.receipt!.allyAmount, Math.ceil(amount / 2));
assert.equal(
result.receipt!.ecazAmount + result.receipt!.allyAmount,
amount,
);
}
}
});
void test('agreement permits either endpoint and conserves every spice unit', () => {
for (const amount of [1, 7, Number.MAX_SAFE_INTEGER])
for (const share of [0, amount]) {
const proposed = quote(create([lot(amount)])!, 'e', {
kind: 'propose',
ecazShare: share,
}).next!;
const receipt = quote(proposed, 'a', { kind: 'accept' }).receipt!;
assert.equal(receipt.ecazAmount, share);
assert.equal(receipt.ecazAmount + receipt.allyAmount, amount);
}
});
void test('multilot JSON recovery settles one lot at a time and restarts the next with Ecaz and no proposal', () => {
let state = create([lot(3), lot(4, 'imperial_basin')])!;
state = quote(state, 'e', { kind: 'propose', ecazShare: 3 }).next!;
const first = quote(reload(state), 'a', { kind: 'accept' });
assert.equal(first.receipt!.territory, 'hagga_basin');
assert.equal(first.next!.index, 1);
assert.equal(first.next!.player, 'e');
assert.equal(first.next!.offer, null);
assert.equal(first.next!.lots.length, 2);
rejected(first.next!, 'a', { kind: 'accept' });
const last = quote(reload(first.next!), 'e', { kind: 'equal' });
assert.equal(last.receipt!.territory, 'imperial_basin');
assert.equal(last.receipt!.ecazAmount, 2);
assert.equal(last.next, null);
});
void test('self acceptance, acceptance without an offer, and noncurrent actors reject without mutation', () => {
const state = create([lot()])!;
rejected(state, 'e', { kind: 'accept' });
rejected(state, 'a', { kind: 'propose', ecazShare: 2 });
rejected(state, 'outside', { kind: 'equal' });
const proposed = quote(state, 'e', { kind: 'propose', ecazShare: 2 }).next!;
rejected(proposed, 'e', { kind: 'accept' });
rejected(proposed, 'e', { kind: 'equal' });
});
void test('malformed lots reject including duplicates and different parties in zero lots', () => {
for (const input of [
[lot(7), lot(0)],
[lot(0), { ...lot(4, 'imperial_basin'), ecaz: 'other' }],
[lot(7), { ...lot(4, 'imperial_basin'), ecaz: 'a', ally: 'e' }],
[{ ...lot(), ally: 'e' }],
[{ ...lot(), ecaz: '' }],
[{ ...lot(), ally: ' ' }],
[lot(2, 'arrakeen')],
[lot(2, 'polar_sink')],
[lot(2, 'hidden_mobile_stronghold')],
[lot(2, 'unknown')],
null,
])
assert.throws(
() => create(input as SharedSpiceLot[]),
SpiceAllocationError,
);
for (const amount of [
-1,
0.5,
NaN,
Infinity,
Number.MAX_SAFE_INTEGER + 1,
'2',
null,
])
assert.throws(() => create([lot(amount as number)]), SpiceAllocationError);
});
void test('the entire saved protocol rejects invalid indices, offers, roles and later lot corruption', () => {
const initial = create([lot(), lot(5, 'imperial_basin')])!;
const mutations: ((s: AllocationState) => void)[] = [
(s) => {
s.index = -1;
},
(s) => {
s.index = 2;
},
(s) => {
s.index = 0.5;
},
(s) => {
s.lots = [];
},
(s) => {
s.player = 'a';
},
(s) => {
s.player = 'unknown';
},
(s) => {
s.lots[1].ally = 'other';
},
(s) => {
s.lots[1].territory = 'hagga_basin';
},
(s) => {
s.lots[1].amount = 0;
},
(s) => {
s.offer = { by: 'e', ecazShare: 2 };
},
(s) => {
s.offer = { by: 'outside', ecazShare: 2 };
},
(s) => {
s.offer = { by: 'a', ecazShare: 8 };
},
(s) => {
s.offer = { by: 'a', ecazShare: NaN };
},
(s) => {
s.offer = undefined as unknown as null;
},
];
for (const mutate of mutations) {
const damaged = reload(initial);
mutate(damaged);
rejected(damaged, damaged.player, { kind: 'equal' });
}
const extra = { ...initial, finished: true };
rejected(extra, 'e', { kind: 'equal' });
});
void test('invalid action shape and numeric proposals never coerce or emit a receipt', () => {
const state = create([lot()])!;
for (const share of [
-1,
8,
0.5,
NaN,
Infinity,
Number.MAX_SAFE_INTEGER + 1,
'2',
null,
])
rejected(state, 'e', { kind: 'propose', ecazShare: share as number });
for (const action of [
{ kind: 'accept', ecazShare: 1 },
{ kind: 'equal', extra: true },
{ kind: 'propose' },
{ kind: 'other' },
null,
])
rejected(state, 'e', action as SpiceAllocationAction);
});
void test('outputs are detached and protocol evaluation cannot consult unrelated resources or randomness', () => {
const initial = create([lot()])!;
const before = reload(initial);
const guarded = new Proxy(initial, {
get(target, key, receiver) {
assert.ok(
['lots', 'index', 'offer', 'player'].includes(String(key)),
String(key),
);
return Reflect.get(target, key, receiver);
},
});
const random = Math.random;
try {
Math.random = () => {
throw Error('Allocation cannot use randomness');
};
const a = quote(guarded, 'e', { kind: 'propose', ecazShare: 2 });
a.next!.lots[0].amount = 100;
a.next!.offer!.ecazShare = 99;
const b = quote(guarded, 'e', { kind: 'propose', ecazShare: 2 });
assert.equal(b.next!.lots[0].amount, 7);
assert.equal(b.next!.offer!.ecazShare, 2);
assert.deepEqual(initial, before);
} finally {
Math.random = random;
}
});