262 lines
8.4 KiB
TypeScript
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;
|
|
}
|
|
});
|