254 lines
15 KiB
TypeScript
254 lines
15 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import {
|
|
answerCoalitionAdmission, areCoalitionAllies, beginCoalitionAdmission, coalitionAllies,
|
|
coalitionEdgeTurn, coalitionGroups, coalitionOf, emptyCoalitionState, leaveCoalition,
|
|
type CoalitionSeat, type CoalitionState, type CoalitionTransition,
|
|
} from '../game/coalition-alliance';
|
|
|
|
function seats(): CoalitionSeat[] {
|
|
return [
|
|
{id: 'a', ally: 'b', allySinceTurn: 2},
|
|
{id: 'b', ally: 'a', allySinceTurn: 2},
|
|
{id: 'c', ally: null},
|
|
{id: 'd', ally: null},
|
|
];
|
|
}
|
|
function apply(players: CoalitionSeat[], transition: CoalitionTransition): CoalitionSeat[] {
|
|
return players.map(player => {
|
|
const next = {...player};
|
|
if (Object.hasOwn(transition.allyUpdates, player.id)) next.ally = transition.allyUpdates[player.id];
|
|
if (Object.hasOwn(transition.allySinceUpdates, player.id)) {
|
|
const value = transition.allySinceUpdates[player.id];
|
|
if (value === null) delete next.allySinceTurn;
|
|
else next.allySinceTurn = value;
|
|
}
|
|
return next;
|
|
});
|
|
}
|
|
function begin(players = seats(), proposer = 'a') {
|
|
const state = emptyCoalitionState();
|
|
const group = coalitionOf(players, state, 'a')!;
|
|
return beginCoalitionAdmission(players, state, {event: 'invite-1', nexus: 'nexus-1', turn: 5,
|
|
groupId: group.id, entrant: 'c', proposer, expectedRevision: 0});
|
|
}
|
|
function answer(players: CoalitionSeat[], state: CoalitionState, actor: string, accept = true) {
|
|
return answerCoalitionAdmission(players, state, {event: 'invite-1', nexus: 'nexus-1', turn: 5,
|
|
actor, accept, expectedRevision: state.revision});
|
|
}
|
|
function triple(players = seats()) {
|
|
const first = begin(players);
|
|
const second = answer(players, first.state, 'b');
|
|
const accepted = answer(players, second.state, 'c');
|
|
return {players: apply(players, accepted), transition: accepted, original: players};
|
|
}
|
|
|
|
void test('legacy reciprocal pairs are derived without inventing an unknown age', () => {
|
|
const original = seats(), before = structuredClone(original);
|
|
const group = coalitionOf(original, undefined, 'a'); assert.ok(group);
|
|
assert.deepEqual(group.members, ['a', 'b']);
|
|
assert.equal(group.formedTurn, 2); assert.equal(coalitionEdgeTurn(original, undefined, 'a', 'b'), 2);
|
|
assert.deepEqual(coalitionAllies(original, undefined, 'a'), ['b']);
|
|
assert.equal(areCoalitionAllies(original, undefined, 'a', 'b'), true);
|
|
assert.equal(areCoalitionAllies(original, undefined, 'a', 'c'), false);
|
|
assert.equal(areCoalitionAllies(original, undefined, 'a', 'a'), false);
|
|
assert.deepEqual(original, before);
|
|
delete original[1].allySinceTurn;
|
|
assert.equal(coalitionEdgeTurn(original, undefined, 'a', 'b'), null);
|
|
assert.equal(areCoalitionAllies(original, undefined, 'a', 'b'), true);
|
|
assert.equal(coalitionOf(original, undefined, 'c'), null);
|
|
});
|
|
|
|
void test('a real pair and free entrant need all three distinct approvals', () => {
|
|
const players = seats(), original = structuredClone(players);
|
|
const first = begin(players); assert.deepEqual(first.state.proposal?.approvals, ['a']);
|
|
assert.deepEqual(first.allyUpdates, {}); assert.deepEqual(players, original);
|
|
const second = answer(players, first.state, 'b');
|
|
assert.deepEqual(second.state.proposal?.approvals, ['a', 'b']);
|
|
assert.deepEqual(second.allyUpdates, {});
|
|
const accepted = answer(players, second.state, 'c');
|
|
assert.equal(accepted.state.proposal, null);
|
|
assert.deepEqual(accepted.allyUpdates, {a: null, b: null, c: null});
|
|
const next = apply(players, accepted);
|
|
assert.deepEqual(coalitionAllies(next, accepted.state, 'a'), ['b', 'c']);
|
|
assert.deepEqual(coalitionAllies(next, accepted.state, 'b'), ['a', 'c']);
|
|
assert.equal(coalitionEdgeTurn(next, accepted.state, 'a', 'b'), 2);
|
|
assert.equal(coalitionEdgeTurn(next, accepted.state, 'a', 'c'), 5);
|
|
assert.equal(coalitionEdgeTurn(next, accepted.state, 'b', 'c'), 5);
|
|
assert.deepEqual(players, original);
|
|
});
|
|
|
|
void test('the free entrant may propose, but both existing members must independently approve', () => {
|
|
const players = seats(), first = begin(players, 'c');
|
|
const second = answer(players, first.state, 'a');
|
|
assert.ok(second.state.proposal); assert.deepEqual(second.state.proposal.approvals, ['c', 'a']);
|
|
const accepted = answer(players, second.state, 'b');
|
|
assert.equal(accepted.state.proposal, null);
|
|
assert.deepEqual(coalitionOf(apply(players, accepted), accepted.state, 'c')?.members, ['a', 'b', 'c']);
|
|
});
|
|
|
|
void test('any participant can decline before commitment; event replay and duplicate consent are rejected', () => {
|
|
const players = seats(), first = begin(players), snapshot = structuredClone(first.state);
|
|
assert.throws(() => answer(players, first.state, 'a'));
|
|
assert.throws(() => answer(players, first.state, 'd'));
|
|
assert.throws(() => answerCoalitionAdmission(players, first.state, {event: 'wrong', nexus: 'nexus-1',
|
|
turn: 5, actor: 'b', accept: true, expectedRevision: first.state.revision}));
|
|
assert.deepEqual(first.state, snapshot);
|
|
const declined = answer(players, first.state, 'a', false);
|
|
assert.equal(declined.state.proposal, null); assert.deepEqual(declined.allyUpdates, {});
|
|
assert.throws(() => answer(players, declined.state, 'b'));
|
|
assert.throws(() => beginCoalitionAdmission(players, declined.state, {event: 'invite-1', nexus: 'nexus-1', turn: 5,
|
|
groupId: coalitionOf(players, declined.state, 'a')!.id, entrant: 'c', proposer: 'a', expectedRevision: declined.state.revision}));
|
|
assert.deepEqual(players, seats());
|
|
});
|
|
|
|
void test('stale revision, changed Nexus and changed original membership never change their inputs', () => {
|
|
const players = seats(), first = begin(players), before = structuredClone({players, state: first.state});
|
|
for (const update of [{expectedRevision: 0}, {nexus: 'different'}, {turn: 6}])
|
|
assert.throws(() => answerCoalitionAdmission(players, first.state, {event: 'invite-1', nexus: 'nexus-1', turn: 5,
|
|
actor: 'b', accept: true, expectedRevision: first.state.revision, ...update}));
|
|
const changed = structuredClone(players); changed[0].ally = null; changed[1].ally = null;
|
|
assert.throws(() => answer(changed, first.state, 'b'));
|
|
assert.deepEqual({players, state: first.state}, before);
|
|
});
|
|
|
|
void test('leaving a triple preserves the remaining pair, stable ID and exact pairwise formation turn', () => {
|
|
for (const actor of ['a', 'b', 'c']) {
|
|
const {players, transition} = triple();
|
|
const before = structuredClone({players, state: transition.state});
|
|
const original = coalitionOf(players, transition.state, actor)!;
|
|
const remaining = original.members.filter(member => member !== actor);
|
|
const expectedAge = coalitionEdgeTurn(players, transition.state, remaining[0], remaining[1]);
|
|
const left = leaveCoalition(players, transition.state, {actor, expectedRevision: transition.state.revision});
|
|
const after = apply(players, left);
|
|
const pair = coalitionOf(after, left.state, remaining[0]); assert.ok(pair);
|
|
assert.equal(pair.id, original.id); assert.deepEqual(pair.members, remaining);
|
|
assert.equal(coalitionEdgeTurn(after, left.state, remaining[0], remaining[1]), expectedAge);
|
|
assert.equal(after.find(seat => seat.id === remaining[0])!.ally, remaining[1]);
|
|
assert.equal(after.find(seat => seat.id === remaining[1])!.ally, remaining[0]);
|
|
assert.equal(coalitionOf(after, left.state, actor), null);
|
|
assert.deepEqual({players, state: transition.state}, before);
|
|
}
|
|
});
|
|
|
|
void test('leaving a legacy pair dissolves it without changing unrelated groups', () => {
|
|
const players: CoalitionSeat[] = [...seats(), {id: 'e', ally: 'f', allySinceTurn: 3},
|
|
{id: 'f', ally: 'e', allySinceTurn: 3}];
|
|
const left = leaveCoalition(players, undefined, {actor: 'a', expectedRevision: 0});
|
|
const after = apply(players, left);
|
|
assert.equal(coalitionOf(after, left.state, 'a'), null);
|
|
assert.equal(coalitionOf(after, left.state, 'b'), null);
|
|
assert.equal(coalitionEdgeTurn(after, left.state, 'e', 'f'), 3);
|
|
});
|
|
|
|
void test('a missing legacy edge date stays unknown when the old pair is restored', () => {
|
|
const initial = seats(); delete initial[0].allySinceTurn; delete initial[1].allySinceTurn;
|
|
const {players, transition} = triple(initial);
|
|
assert.equal(coalitionEdgeTurn(players, transition.state, 'a', 'b'), null);
|
|
assert.equal(coalitionEdgeTurn(players, transition.state, 'a', 'c'), 5);
|
|
const left = leaveCoalition(players, transition.state, {actor: 'c', expectedRevision: transition.state.revision});
|
|
assert.equal(left.allySinceUpdates.a, null); assert.equal(left.allySinceUpdates.b, null);
|
|
const restored = apply(players, left);
|
|
assert.equal(coalitionEdgeTurn(restored, left.state, 'a', 'b'), null);
|
|
assert.equal(areCoalitionAllies(restored, left.state, 'a', 'b'), true);
|
|
});
|
|
|
|
void test('invalid saved membership, malformed edges and fake scalar triple pairs are rejected', () => {
|
|
const initial = seats();
|
|
const dangling = structuredClone(initial); dangling[1].ally = null;
|
|
assert.throws(() => coalitionGroups(dangling, undefined));
|
|
const {players, transition} = triple();
|
|
assert.throws(() => coalitionGroups(initial, transition.state));
|
|
const duplicate = structuredClone(transition.state);
|
|
duplicate.groups.push({...structuredClone(duplicate.groups[0]), id: 'duplicate'});
|
|
assert.throws(() => coalitionGroups(players, duplicate));
|
|
const missing = structuredClone(transition.state); missing.groups[0].edges.pop();
|
|
assert.throws(() => coalitionGroups(players, missing));
|
|
const wrongAge = structuredClone(transition.state); wrongAge.groups[0].edges[0].formedTurn = -1;
|
|
assert.throws(() => coalitionGroups(players, wrongAge));
|
|
});
|
|
|
|
void test('saved invitations reject unrelated seats, duplicate approvals and state changed under the offer', () => {
|
|
const players = seats(), first = begin(players);
|
|
for (const corrupt of [
|
|
(state: CoalitionState) => { state.proposal!.approvals.push('d'); },
|
|
(state: CoalitionState) => { state.proposal!.approvals.push('a'); },
|
|
(state: CoalitionState) => { state.proposal!.original.edges[0].formedTurn = 4; },
|
|
(state: CoalitionState) => { state.usedEvents = []; },
|
|
]) {
|
|
const broken = structuredClone(first.state); corrupt(broken);
|
|
assert.throws(() => coalitionGroups(players, broken));
|
|
assert.throws(() => answer(players, broken, 'b'));
|
|
}
|
|
});
|
|
|
|
void test('unequal legacy scalar ages survive joining and restoring the original pair', () => {
|
|
for (const dates of [[2, undefined], [undefined, 3], [2, 3]] as const) {
|
|
for (const persistedPair of [false, true]) {
|
|
const initial = seats();
|
|
for (const [index, date] of dates.entries()) {
|
|
if (date === undefined) delete initial[index].allySinceTurn;
|
|
else initial[index].allySinceTurn = date;
|
|
}
|
|
const derived = coalitionOf(initial, undefined, 'a')!;
|
|
assert.equal(derived.edges[0].formedTurn, null);
|
|
// An older persisted pair may predate the per-seat metadata.
|
|
const oldPair = structuredClone(derived); delete oldPair.edges[0].legacySince;
|
|
const state = {...emptyCoalitionState(), groups: persistedPair ? [oldPair] : []};
|
|
const first = beginCoalitionAdmission(initial, state, {event: 'invite-1', nexus: 'nexus-1', turn: 5,
|
|
groupId: derived.id, entrant: 'c', proposer: 'a', expectedRevision: 0});
|
|
const second = answer(initial, first.state, 'b');
|
|
const accepted = answer(initial, second.state, 'c');
|
|
const players = apply(initial, accepted);
|
|
assert.equal(coalitionEdgeTurn(players, accepted.state, 'a', 'b'), null);
|
|
const left = leaveCoalition(players, accepted.state, {actor: 'c', expectedRevision: accepted.state.revision});
|
|
const restored = apply(players, left);
|
|
assert.equal(restored[0].allySinceTurn, dates[0]);
|
|
assert.equal(restored[1].allySinceTurn, dates[1]);
|
|
assert.equal(Object.hasOwn(restored[0], 'allySinceTurn'), dates[0] !== undefined);
|
|
assert.equal(Object.hasOwn(restored[1], 'allySinceTurn'), dates[1] !== undefined);
|
|
assert.equal(coalitionEdgeTurn(restored, left.state, 'a', 'b'), null);
|
|
assert.equal(coalitionOf(restored, left.state, 'a')!.id, derived.id);
|
|
}
|
|
}
|
|
});
|
|
|
|
void test('maximum-length seat IDs with escaped characters can be admitted and persisted', () => {
|
|
const a = '\u0001'.repeat(256), b = '\u0002'.repeat(256);
|
|
const players: CoalitionSeat[] = [{id: a, ally: b, allySinceTurn: 2},
|
|
{id: b, ally: a, allySinceTurn: 2}, {id: 'entrant', ally: null}];
|
|
const group = coalitionOf(players, undefined, a)!;
|
|
assert.ok(group.id.length > 256);
|
|
const first = beginCoalitionAdmission(players, undefined, {event: 'long-id', nexus: 'nexus', turn: 5,
|
|
groupId: group.id, entrant: 'entrant', proposer: a, expectedRevision: 0});
|
|
const second = answerCoalitionAdmission(players, first.state, {event: 'long-id', nexus: 'nexus', turn: 5,
|
|
actor: b, accept: true, expectedRevision: first.state.revision});
|
|
const accepted = answerCoalitionAdmission(players, second.state, {event: 'long-id', nexus: 'nexus', turn: 5,
|
|
actor: 'entrant', accept: true, expectedRevision: second.state.revision});
|
|
const joined = apply(players, accepted);
|
|
assert.equal(coalitionOf(joined, accepted.state, a)!.id, group.id);
|
|
const left = leaveCoalition(joined, accepted.state, {actor: 'entrant', expectedRevision: accepted.state.revision});
|
|
assert.equal(coalitionOf(apply(joined, left), left.state, a)!.id, group.id);
|
|
});
|
|
|
|
void test('an older unknown edge retains matching saved scalar dates without inventing a shared date', () => {
|
|
const initial = seats();
|
|
initial[0].allySinceTurn = 2; initial[1].allySinceTurn = 2;
|
|
const oldPair = structuredClone(coalitionOf(initial, undefined, 'a')!);
|
|
oldPair.formedTurn = null; oldPair.edges[0].formedTurn = null;
|
|
const state = {...emptyCoalitionState(), groups: [oldPair]};
|
|
const first = beginCoalitionAdmission(initial, state, {event: 'old-pair', nexus: 'nexus-1', turn: 5,
|
|
groupId: oldPair.id, entrant: 'c', proposer: 'a', expectedRevision: 0});
|
|
const second = answerCoalitionAdmission(initial, first.state, {event: 'old-pair', nexus: 'nexus-1', turn: 5,
|
|
actor: 'b', accept: true, expectedRevision: first.state.revision});
|
|
const accepted = answerCoalitionAdmission(initial, second.state, {event: 'old-pair', nexus: 'nexus-1', turn: 5,
|
|
actor: 'c', accept: true, expectedRevision: second.state.revision});
|
|
const joined = apply(initial, accepted);
|
|
assert.equal(coalitionEdgeTurn(joined, accepted.state, 'a', 'b'), null);
|
|
const left = leaveCoalition(joined, accepted.state, {actor: 'c', expectedRevision: accepted.state.revision});
|
|
const restored = apply(joined, left);
|
|
assert.equal(restored[0].allySinceTurn, 2);
|
|
assert.equal(restored[1].allySinceTurn, 2);
|
|
assert.equal(coalitionEdgeTurn(restored, left.state, 'a', 'b'), null);
|
|
});
|