308 lines
19 KiB
TypeScript
308 lines
19 KiB
TypeScript
/** 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<string, string | null>;
|
|
/** null means delete/unknown, rather than manufacturing a formation date. */
|
|
allySinceUpdates: Record<string, number | null>;
|
|
};
|
|
|
|
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<string, unknown> =>
|
|
!!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 = <T>(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<string>(), ids = new Set<string>();
|
|
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)};
|
|
}
|