/** Foundation for optional larger alliances. The engine must still * authorize Nexus timing, apply legacy-field updates atomically, and connect * each native alliance benefit before enabling this option in play. */ export type CoalitionSeat = { id: string; ally: string | null; allySinceTurn?: number }; export type CoalitionEdge = { members: [string, string]; formedTurn: number | null; /** Original scalar dates when the shared age is unknown; null means absent. */ legacySince?: [number | null, number | null]; }; export type CoalitionGroup = { id: string; members: string[]; /** Original group formation; null preserves an unknown legacy date. */ formedTurn: number | null; edges: CoalitionEdge[]; }; export type CoalitionAdmission = { event: string; nexus: string; turn: number; groupId: string; /** Snapshot binds the offer to the exact existing group and its edge ages. */ original: CoalitionGroup; entrant: string; proposer: string; approvals: string[]; }; export type CoalitionState = { version: 1; /** Absent preserves the existing three-member limit and target selection. * The distinct short variant retains that cap and uses the short target. */ variant?: 'three-short' | 'unlimited'; revision: number; groups: CoalitionGroup[]; proposal: CoalitionAdmission | null; usedEvents: string[]; }; export type CoalitionTransition = { state: CoalitionState; /** Apply these with the new state in one authoritative action. */ allyUpdates: Record; /** null means delete/unknown, rather than manufacturing a formation date. */ allySinceUpdates: Record; }; export class CoalitionAllianceError extends Error { constructor(message: string) { super(message); this.name = 'CoalitionAllianceError'; } } function check(condition: unknown, message: string): asserts condition { if (!condition) throw new CoalitionAllianceError(message); } const record = (value: unknown): value is Record => !!value && typeof value === 'object' && !Array.isArray(value); const id = (value: unknown): value is string => typeof value === 'string' && value.length > 0 && value.length <= 256 && value.trim() === value; // JSON escaping can expand each 256-character seat ID to six characters. const groupId = (value: unknown): value is string => typeof value === 'string' && value.length > 0 && value.length <= 4096 && value.trim() === value; const turn = (value: unknown): value is number => Number.isSafeInteger(value) && Number(value) > 0; const age = (value: unknown): value is number | null => value === null || turn(value); const sorted = (values: readonly string[]) => [...values].sort(); const pair = (a: string, b: string): [string, string] => sorted([a, b]) as [string, string]; const same = (left: unknown, right: unknown) => JSON.stringify(left) === JSON.stringify(right); const copy = (value: T): T => structuredClone(value); const legacyId = (members: readonly string[]) => `legacy:${JSON.stringify(sorted(members))}`; export function emptyCoalitionState(variant?: CoalitionState['variant']): CoalitionState { return { version: 1, ...(variant === undefined ? {} : {variant}), revision: 0, groups: [], proposal: null, usedEvents: [] }; } function requireSeats(seats: readonly CoalitionSeat[]): void { check(Array.isArray(seats) && seats.length >= 2 && seats.length <= 6 && seats.every(seat => record(seat) && id(seat.id) && (seat.ally === null || id(seat.ally)) && (seat.allySinceTurn === undefined || turn(seat.allySinceTurn))) && new Set(seats.map(seat => seat.id)).size === seats.length, 'Coalitions need two through six distinct seated players.'); } function requireGroup(group: CoalitionGroup, seats: readonly CoalitionSeat[], maximum: number): void { check(record(group) && groupId(group.id) && Array.isArray(group.members) && group.members.length >= 2 && group.members.length <= maximum && group.members.every(id) && new Set(group.members).size === group.members.length && same(group.members, sorted(group.members)) && group.members.every(member => seats.some(seat => seat.id === member)) && age(group.formedTurn) && Array.isArray(group.edges), 'Invalid saved coalition.'); const expected: string[] = []; for (let i = 0; i < group.members.length; i++) for (let j = i + 1; j < group.members.length; j++) expected.push(JSON.stringify([group.members[i], group.members[j]])); const actual = group.edges.map(edge => { check(record(edge) && Array.isArray(edge.members) && edge.members.length === 2 && edge.members.every(id) && edge.members[0] !== edge.members[1] && same(edge.members, sorted(edge.members)) && age(edge.formedTurn) && (edge.legacySince === undefined || (Array.isArray(edge.legacySince) && edge.legacySince.length === 2 && edge.legacySince.every(age) && edge.formedTurn === null && edge.legacySince.some(date => date !== null))), 'Invalid coalition edge.'); return JSON.stringify(edge.members); }); check(same(actual, expected), 'A coalition must retain exactly one ordered edge for each pair.'); } function requireState(state: CoalitionState): void { check(record(state) && state.version === 1 && (state.variant === undefined || state.variant === 'three-short' || state.variant === 'unlimited') && Number.isSafeInteger(state.revision) && state.revision >= 0 && Array.isArray(state.groups) && Array.isArray(state.usedEvents) && state.usedEvents.every(id) && new Set(state.usedEvents).size === state.usedEvents.length && (state.proposal === null || record(state.proposal)), 'Invalid saved coalition state.'); } /** Combine explicit groups with validated, unmodified reciprocal legacy pairs. * Derived IDs are deterministic; no historical age is invented. */ export function coalitionGroups(seats: readonly CoalitionSeat[], value?: CoalitionState): CoalitionGroup[] { requireSeats(seats); check(value !== null, 'A coalition ledger cannot be null.'); const state = value ?? emptyCoalitionState(); requireState(state); const seen = new Set(), ids = new Set(); for (const group of state.groups) { requireGroup(group, seats, state.variant === 'unlimited' ? 6 : 3); check(!ids.has(group.id) && group.members.every(member => !seen.has(member)), 'A seat or coalition ID cannot belong to multiple groups.'); ids.add(group.id); group.members.forEach(member => seen.add(member)); if (group.members.length >= 3) { check(group.members.every(member => seats.find(seat => seat.id === member)!.ally === null), 'Larger groups cannot use a fictitious legacy pair.'); } else { const [a, b] = group.members; check(seats.find(seat => seat.id === a)!.ally === b && seats.find(seat => seat.id === b)!.ally === a, 'A saved two-member group must retain its reciprocal legacy pair.'); const edgeTurn = group.edges[0].formedTurn; if (edgeTurn !== null) check(group.members.every(member => seats.find(seat => seat.id === member)!.allySinceTurn === edgeTurn), 'A saved pair must retain the original edge formation turn.'); const legacySince = group.edges[0].legacySince; if (legacySince) check(group.members.every((member, index) => (seats.find(seat => seat.id === member)!.allySinceTurn ?? null) === legacySince[index]), 'A saved pair must retain each legacy scalar formation turn.'); } } const groups = state.groups.map(copy); // Older explicit pair records may have a shared unknown date but still hold // per-seat scalars. Capture them without inferring a shared edge date. for (const group of groups) if (group.members.length === 2 && group.edges[0].formedTurn === null && group.edges[0].legacySince === undefined) { const dates = group.members.map(member => seats.find(seat => seat.id === member)!.allySinceTurn ?? null); if (dates.some(date => date !== null)) group.edges[0].legacySince = dates as [number | null, number | null]; } for (const seat of seats) { if (seat.ally === null || seen.has(seat.id)) continue; const other = seats.find(candidate => candidate.id === seat.ally); check(other && other.id !== seat.id && other.ally === seat.id && !seen.has(other.id), 'Legacy alliances must be seated and reciprocal.'); const members = pair(seat.id, other.id); const formedTurn = seat.allySinceTurn !== undefined && seat.allySinceTurn === other.allySinceTurn ? seat.allySinceTurn : null; const group: CoalitionGroup = { id: legacyId(members), members, formedTurn, edges: [{ members: [...members], formedTurn, ...(formedTurn === null && seat.allySinceTurn !== other.allySinceTurn ? { legacySince: members.map(member => seats.find(candidate => candidate.id === member)!.allySinceTurn ?? null) as [number | null, number | null], } : {}) }] }; check(!ids.has(group.id), 'A legacy alliance conflicts with a saved group ID.'); ids.add(group.id); members.forEach(member => seen.add(member)); groups.push(group); } // A seat in a persisted group may not be the dangling target of an outsider. for (const seat of seats) if (seat.ally !== null) { const other = seats.find(candidate => candidate.id === seat.ally); check(other && other.id !== seat.id && other.ally === seat.id, 'Legacy alliances must be seated and reciprocal.'); } if (state.proposal) { const offer = state.proposal; check(id(offer.event) && id(offer.nexus) && turn(offer.turn) && groupId(offer.groupId) && id(offer.entrant) && id(offer.proposer) && Array.isArray(offer.approvals) && offer.approvals.every(id) && new Set(offer.approvals).size === offer.approvals.length && state.usedEvents.includes(offer.event), 'Invalid saved coalition invitation.'); const group = groups.find(candidate => candidate.id === offer.groupId); check(group && group.members.length < (state.variant === 'unlimited' ? 6 : 3) && same(group, offer.original) && seats.some(seat => seat.id === offer.entrant) && !groups.some(candidate => candidate.members.includes(offer.entrant)), 'The saved invitation no longer matches its group or entrant.'); const participants = [...group.members, offer.entrant]; check(participants.includes(offer.proposer) && offer.approvals.includes(offer.proposer) && offer.approvals.every(actor => participants.includes(actor)) && offer.approvals.length < participants.length && (group.formedTurn === null || group.formedTurn <= offer.turn) && group.edges.every(edge => (edge.formedTurn === null || edge.formedTurn <= offer.turn) && (!edge.legacySince || edge.legacySince.every(date => date === null || date <= offer.turn))), 'The saved invitation has invalid approvals or timing.'); } return groups; } export function coalitionOf(seats: readonly CoalitionSeat[], state: CoalitionState | undefined, player: string): CoalitionGroup | null { const groups = coalitionGroups(seats, state); check(id(player) && seats.some(seat => seat.id === player), 'Choose a seated player.'); return groups.find(group => group.members.includes(player)) ?? null; } export function coalitionAllies(seats: readonly CoalitionSeat[], state: CoalitionState | undefined, player: string): string[] { return coalitionOf(seats, state, player)?.members.filter(member => member !== player) ?? []; } export function areCoalitionAllies(seats: readonly CoalitionSeat[], state: CoalitionState | undefined, a: string, b: string): boolean { check(id(b) && seats.some(seat => seat.id === b), 'Choose a seated player.'); return a !== b && coalitionAllies(seats, state, a).includes(b); } /** null means either no edge or an unknown legacy date; test ally membership separately. */ export function coalitionEdgeTurn(seats: readonly CoalitionSeat[], state: CoalitionState | undefined, a: string, b: string): number | null { if (!areCoalitionAllies(seats, state, a, b)) return null; return coalitionOf(seats, state, a)!.edges.find(edge => same(edge.members, pair(a, b)))!.formedTurn; } export type CoalitionAdmissionRequest = { event: string; nexus: string; turn: number; groupId: string; entrant: string; proposer: string; expectedRevision: number }; export type CoalitionAdmissionAnswer = { event: string; nexus: string; turn: number; actor: string; accept: boolean; expectedRevision: number }; function currentState(seats: readonly CoalitionSeat[], value: CoalitionState | undefined, expectedRevision: number) { check(value !== null, 'A coalition ledger cannot be null.'); const state = value ?? emptyCoalitionState(); const groups = coalitionGroups(seats, state); check(Number.isSafeInteger(expectedRevision) && expectedRevision === state.revision && expectedRevision < Number.MAX_SAFE_INTEGER, 'The coalition revision is stale or exhausted.'); return {state, groups}; } function unchanged(state: CoalitionState): CoalitionTransition { return {state, allyUpdates: {}, allySinceUpdates: {}}; } /** A proposer is either member of the group or the free entrant; its offer is * its explicit consent. A later decline by any participant cancels the offer. */ export function beginCoalitionAdmission(seats: readonly CoalitionSeat[], value: CoalitionState | undefined, request: CoalitionAdmissionRequest): CoalitionTransition { check(record(request) && id(request.event) && id(request.nexus) && turn(request.turn) && groupId(request.groupId) && id(request.entrant) && id(request.proposer), 'Choose an event-bound coalition invitation.'); const {state, groups} = currentState(seats, value, request.expectedRevision); check(state.proposal === null && !state.usedEvents.includes(request.event), 'Finish the current invitation and use a new event.'); const group = groups.find(candidate => candidate.id === request.groupId); check(group && group.members.length < (state.variant === 'unlimited' ? 6 : 3) && seats.some(seat => seat.id === request.entrant) && !groups.some(candidate => candidate.members.includes(request.entrant)) && (group.members.includes(request.proposer) || request.proposer === request.entrant) && (group.formedTurn === null || group.formedTurn <= request.turn) && group.edges.every(edge => (edge.formedTurn === null || edge.formedTurn <= request.turn) && (!edge.legacySince || edge.legacySince.every(date => date === null || date <= request.turn))), 'Invite one unallied seated player to an eligible existing alliance.'); return unchanged({ ...copy(state), revision: state.revision + 1, usedEvents: [...state.usedEvents, request.event], proposal: {event: request.event, nexus: request.nexus, turn: request.turn, groupId: group.id, original: copy(group), entrant: request.entrant, proposer: request.proposer, approvals: [request.proposer]} }); } /** Every current group member and the entrant must explicitly approve. */ export function answerCoalitionAdmission(seats: readonly CoalitionSeat[], value: CoalitionState | undefined, answer: CoalitionAdmissionAnswer): CoalitionTransition { check(record(answer) && id(answer.event) && id(answer.nexus) && turn(answer.turn) && id(answer.actor) && typeof answer.accept === 'boolean', 'Choose an event-bound alliance answer.'); const {state, groups} = currentState(seats, value, answer.expectedRevision); const offer = state.proposal; check(offer && id(offer.event) && id(offer.nexus) && turn(offer.turn) && groupId(offer.groupId) && id(offer.entrant) && id(offer.proposer) && Array.isArray(offer.approvals) && offer.approvals.every(id) && new Set(offer.approvals).size === offer.approvals.length && state.usedEvents.includes(offer.event), 'The saved coalition invitation is invalid.'); const group = groups.find(candidate => candidate.id === offer.groupId); check(group && group.members.length < (state.variant === 'unlimited' ? 6 : 3) && same(group, offer.original) && !groups.some(candidate => candidate.members.includes(offer.entrant)) && seats.some(seat => seat.id === offer.entrant), 'The original group or entrant changed.'); const participants = [...group.members, offer.entrant]; check(participants.includes(offer.proposer) && offer.approvals.includes(offer.proposer) && offer.approvals.every(actor => participants.includes(actor)) && answer.event === offer.event && answer.nexus === offer.nexus && answer.turn === offer.turn && participants.includes(answer.actor), 'This invitation answer is stale or belongs to another player.'); if (!answer.accept) return unchanged({ ...copy(state), revision: state.revision + 1, proposal: null }); check(!offer.approvals.includes(answer.actor), 'This player already approved the invitation.'); const approvals = [...offer.approvals, answer.actor]; if (approvals.length < participants.length) return unchanged({ ...copy(state), revision: state.revision + 1, proposal: {...copy(offer), approvals} }); const members = sorted(participants); const edges: CoalitionEdge[] = []; for (let i = 0; i < members.length; i++) for (let j = i + 1; j < members.length; j++) { const membersPair = pair(members[i], members[j]); const old = group.edges.find(edge => same(edge.members, membersPair)); edges.push(old ? copy(old) : {members: membersPair, formedTurn: offer.turn}); } const nextGroup: CoalitionGroup = { id: group.id, members, formedTurn: group.formedTurn, edges }; const nextGroups = [...state.groups.filter(item => item.id !== group.id).map(copy), nextGroup]; return {state: {...copy(state), revision: state.revision + 1, groups: nextGroups, proposal: null}, allyUpdates: Object.fromEntries(members.map(member => [member, null])), allySinceUpdates: {}}; } /** Provisional online policy: remaining members stay allied, preserving each * surviving edge and group identity. A pair restores its legacy scalar fields. */ export function leaveCoalition(seats: readonly CoalitionSeat[], value: CoalitionState | undefined, request: {actor: string; expectedRevision: number}): CoalitionTransition { check(record(request) && id(request.actor), 'Choose the departing player.'); const {state, groups} = currentState(seats, value, request.expectedRevision); check(state.proposal === null, 'Finish or decline the pending invitation first.'); const group = groups.find(item => item.members.includes(request.actor)); check(group, 'This player is not in an alliance.'); const remaining = group.members.filter(member => member !== request.actor); const retained = remaining.length === 2 ? group.edges.find(edge => same(edge.members, remaining)) : undefined; const nextGroups = state.groups.filter(item => item.id !== group.id).map(copy); const allyEntries: [string, string | null][] = [[request.actor, null]]; const ageEntries: [string, number | null][] = [[request.actor, null]]; if (retained) { nextGroups.push({id: group.id, members: remaining, formedTurn: retained.formedTurn, edges: [copy(retained)]}); allyEntries.push([remaining[0], remaining[1]], [remaining[1], remaining[0]]); const retainedDates = retained.legacySince ?? [retained.formedTurn, retained.formedTurn]; ageEntries.push([remaining[0], retainedDates[0]], [remaining[1], retainedDates[1]]); } else if (remaining.length >= 3) { const edges = group.edges.filter(edge => edge.members.every(member => remaining.includes(member))).map(copy); nextGroups.push({id: group.id, members: remaining, formedTurn: group.formedTurn, edges}); for (const member of remaining) allyEntries.push([member, null]); } else for (const member of remaining) { allyEntries.push([member, null]); ageEntries.push([member, null]); } return {state: {...copy(state), revision: state.revision + 1, groups: nextGroups}, allyUpdates: Object.fromEntries(allyEntries), allySinceUpdates: Object.fromEntries(ageEntries)}; }