import assert from 'node:assert/strict'; import test from 'node:test'; import { beginCoalitionAdmission, answerCoalitionAdmission, coalitionAllies, coalitionGroups, coalitionOf, emptyCoalitionState, leaveCoalition, type CoalitionSeat, type CoalitionState, type CoalitionTransition, } from '../game/coalition-alliance'; function initial() { const seats: CoalitionSeat[] = [ {id:'a', ally:'b', allySinceTurn:1}, {id:'b', ally:'a', allySinceTurn:2}, ...['c','d','e','f'].map(id => ({id, ally:null})), ]; return {seats, state:emptyCoalitionState('unlimited')}; } function apply(seats: CoalitionSeat[], transition: CoalitionTransition): CoalitionState { for (const seat of seats) { if (Object.hasOwn(transition.allyUpdates, seat.id)) seat.ally = transition.allyUpdates[seat.id]; if (Object.hasOwn(transition.allySinceUpdates, seat.id)) { const value = transition.allySinceUpdates[seat.id]; if (value === null) delete seat.allySinceTurn; else seat.allySinceTurn = value; } } return transition.state; } function invite(seats: CoalitionSeat[], state: CoalitionState, entrant: string, turn: number): CoalitionState { const group = coalitionOf(seats, state, 'a'); assert.ok(group); const event = `invite-${entrant}-${turn}`; state = apply(seats, beginCoalitionAdmission(seats, state, {event, nexus:`nexus-${turn}`, turn, groupId:group.id, entrant, proposer:'a', expectedRevision:state.revision})); const participants = [...group.members.filter(member => member !== 'a'), entrant]; for (const actor of participants) { const previous = coalitionOf(seats, state, 'a')!.members; state = apply(seats, answerCoalitionAdmission(seats, state, {event, nexus:`nexus-${turn}`, turn, actor, accept:true, expectedRevision:state.revision})); if (actor !== entrant) assert.deepEqual(coalitionOf(seats, state, 'a')!.members, previous, 'The existing group does not expand before every participant approves.'); } return state; } void test('unlimited membership grows to six with unanimous approval and retained edge ages', () => { const {seats} = initial(); let state = emptyCoalitionState('unlimited'); for (const [index, entrant] of ['c','d','e','f'].entries()) state = invite(seats, state, entrant, index + 3); const group = coalitionOf(seats,state,'a'); assert.ok(group); assert.deepEqual(group.members,['a','b','c','d','e','f']); assert.equal(group.edges.length,15); assert.deepEqual(group.edges.find(edge => edge.members.join('') === 'ab')?.legacySince,[1,2]); assert.equal(group.edges.find(edge => edge.members.join('') === 'ad')?.formedTurn,4); assert.deepEqual(coalitionAllies(seats,state,'a'),['b','c','d','e','f']); assert.ok(seats.every(seat => seat.ally === null)); assert.equal(coalitionGroups(seats, JSON.parse(JSON.stringify(state))).length,1); const before = structuredClone(state); assert.throws(() => beginCoalitionAdmission(seats,state,{event:'full',nexus:'n',turn:8, groupId:group.id,entrant:'f',proposer:'a',expectedRevision:state.revision})); assert.deepEqual(state,before); }); void test('departures preserve surviving edges and restore the original pair scalar ages', () => { const {seats} = initial(); let state = emptyCoalitionState('unlimited'); state = invite(seats,state,'c',3); state = invite(seats,state,'d',4); state = apply(seats,leaveCoalition(seats,state,{actor:'d',expectedRevision:state.revision})); let group = coalitionOf(seats,state,'a'); assert.ok(group); assert.deepEqual(group.members,['a','b','c']); assert.equal(group.edges.length,3); assert.equal(group.edges.find(edge => edge.members.join('') === 'ac')?.formedTurn,3); state = apply(seats,leaveCoalition(seats,state,{actor:'c',expectedRevision:state.revision})); group = coalitionOf(seats,state,'a'); assert.ok(group); assert.deepEqual(group.members,['a','b']); assert.equal(seats[0].ally,'b'); assert.equal(seats[1].ally,'a'); assert.equal(seats[0].allySinceTurn,1); assert.equal(seats[1].allySinceTurn,2); assert.equal(seats[2].ally,null); assert.equal(seats[3].ally,null); state = invite(seats,state,'d',5); assert.deepEqual(coalitionOf(seats,state,'a')?.members,['a','b','d']); assert.deepEqual(coalitionOf(seats,state,'a')?.edges.find(edge => edge.members.join('') === 'ab')?.legacySince,[1,2]); }); void test('decline, stale answers and an omitted variant preserve the original alliance', () => { const {seats} = initial(); let state = emptyCoalitionState('unlimited'); state = invite(seats,state,'c',3); const group = coalitionOf(seats,state,'a'); assert.ok(group); state = apply(seats,beginCoalitionAdmission(seats,state,{event:'invite-d',nexus:'n',turn:4, groupId:group.id,entrant:'d',proposer:'d',expectedRevision:state.revision})); const before = structuredClone(state); assert.throws(() => answerCoalitionAdmission(seats,state,{event:'invite-d',nexus:'n',turn:4, actor:'a',accept:true,expectedRevision:state.revision - 1})); assert.deepEqual(state,before); state = apply(seats,answerCoalitionAdmission(seats,state,{event:'invite-d',nexus:'n',turn:4, actor:'b',accept:false,expectedRevision:state.revision})); assert.equal(state.proposal,null); assert.deepEqual(coalitionOf(seats,state,'a')?.members,['a','b','c']); const defaultState = {...state}; delete defaultState.variant; assert.throws(() => beginCoalitionAdmission(seats,defaultState,{event:'new',nexus:'n',turn:4, groupId:group.id,entrant:'d',proposer:'a',expectedRevision:defaultState.revision})); assert.throws(() => coalitionGroups(seats,{...state,variant:'invalid'} as unknown as CoalitionState)); });