Prototype unlimited alliance variant
Verify and publish checkpoint container / container (push) Canceled after 0s

This commit is contained in:
Quintine committed 2026-10-29 00:33:42 +11:00
1 parent 37acaeb9d2
commit 9cb65a21f5
23 files changed
+677 -52

No files matched your search

+27 -20
View File
@@ -1,4 +1,4 @@
/** Foundation for the optional three-member alliance. The engine must still
/** 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 };
@@ -20,7 +20,7 @@ export type CoalitionAdmission = {
nexus: string;
turn: number;
groupId: string;
/** Snapshot binds the offer to the exact existing pair and its edge age. */
/** Snapshot binds the offer to the exact existing group and its edge ages. */
original: CoalitionGroup;
entrant: string;
proposer: string;
@@ -28,6 +28,8 @@ export type CoalitionAdmission = {
};
export type CoalitionState = {
version: 1;
/** Absent preserves the existing three-member maximum. */
variant?: 'unlimited';
revision: number;
groups: CoalitionGroup[];
proposal: CoalitionAdmission | null;
@@ -62,8 +64,8 @@ const same = (left: unknown, right: unknown) => JSON.stringify(left) === JSON.st
const copy = <T>(value: T): T => structuredClone(value);
const legacyId = (members: readonly string[]) => `legacy:${JSON.stringify(sorted(members))}`;
export function emptyCoalitionState(): CoalitionState {
return { version: 1, revision: 0, groups: [], proposal: null, usedEvents: [] };
export function emptyCoalitionState(variant?: 'unlimited'): 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 &&
@@ -72,9 +74,9 @@ function requireSeats(seats: readonly CoalitionSeat[]): void {
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[]): void {
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 === 3) && group.members.every(id) &&
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.');
@@ -93,7 +95,8 @@ function requireGroup(group: CoalitionGroup, seats: readonly CoalitionSeat[]): v
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 && Number.isSafeInteger(state.revision) && state.revision >= 0 &&
check(record(state) && state.version === 1 && (state.variant === undefined || 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.');
@@ -108,13 +111,13 @@ export function coalitionGroups(seats: readonly CoalitionSeat[], value?: Coaliti
requireState(state);
const seen = new Set<string>(), ids = new Set<string>();
for (const group of state.groups) {
requireGroup(group, seats);
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) {
if (group.members.length >= 3) {
check(group.members.every(member => seats.find(seat => seat.id === member)!.ally === null),
'Three-member groups cannot use a fictitious legacy pair.');
'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,
@@ -166,10 +169,10 @@ export function coalitionGroups(seats: readonly CoalitionSeat[], value?: Coaliti
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 === 2 && same(group, offer.original) &&
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 pair or 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 &&
@@ -213,7 +216,7 @@ function unchanged(state: CoalitionState): CoalitionTransition {
return {state, allyUpdates: {}, allySinceUpdates: {}};
}
/** A proposer is either member of the pair or the free entrant; its offer is
/** 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 {
@@ -222,19 +225,19 @@ export function beginCoalitionAdmission(seats: readonly CoalitionSeat[], value:
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 === 2 && seats.some(seat => seat.id === request.entrant) &&
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 existing pair.');
'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 pair member and the entrant must explicitly approve. */
/** 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) &&
@@ -246,9 +249,9 @@ export function answerCoalitionAdmission(seats: readonly CoalitionSeat[], value:
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 === 2 && same(group, offer.original) &&
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 pair or entrant changed.');
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)) &&
@@ -273,8 +276,8 @@ export function answerCoalitionAdmission(seats: readonly CoalitionSeat[], value:
allyUpdates: Object.fromEntries(members.map(member => [member, null])), allySinceUpdates: {}};
}
/** Provisional online policy: the departing member leaves; the remaining pair
* stays allied, preserving that pair's original edge date and group identity. */
/** 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.');
@@ -292,6 +295,10 @@ export function leaveCoalition(seats: readonly CoalitionSeat[], value: Coalition
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)};