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); });