Initial commit
This commit is contained in:
commit
8c709fb52f
771 files changed
+222983
No files matched your search
@@ -0,0 +1,261 @@
|
||||
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;
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user