Files
Dune/game/coalition-alliance.ts
T
Quintine ef92ddf2b9
Verify and publish checkpoint container / container (push) Canceled after 0s
Add three-member short alliance prototype
2026-10-29 02:31:50 +11:00

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