Allow group admission with compatible commitments
Verify and publish checkpoint container / container (push) Canceled after 0s

This commit is contained in:
Quintine committed 2026-10-29 00:54:36 +11:00
1 parent 0faa7923b0
commit 6524ed3bf2
14 files changed
+679 -53

No files matched your search

+80 -18
View File
@@ -16036,9 +16036,6 @@ function allianceQuestionWindow(g: Game): boolean {
function unsettledPromises(records: readonly {fulfilled?: boolean; released?: boolean}[] | undefined): boolean {
return !!records?.some(record => !record.fulfilled && !record.released);
}
function coalitionAdmissionBlocked(g: Game): boolean {
return [g.bribePromises, g.bribeExpressionPromises].some(unsettledPromises);
}
/** Group negotiation is deliberately unexposed until its other alliance
* mechanics are integrated. This narrow profile keeps existing pair games
* and unrelated commitments on their original paths. */
@@ -16058,6 +16055,54 @@ function coalitionRule<T>(work: () => T): T {
try { return work(); }
catch (error) { if (error instanceof CoalitionAllianceError || error instanceof CoalitionFundingError) throw new RuleError(error.message); throw error; }
}
/** Quote only this participant's owned commitments against the completed
* proposed group. Synthetic approvals and escrow epochs stay on the clone. */
function coalitionAdmissionAllowed(g: Game, actor: string, groupId: string, entrant: string): boolean {
const bribes = [...(g.bribePromises ?? []), ...(g.bribeExpressionPromises ?? [])];
const alliances = [...(g.alliancePromises ?? []), ...(g.allianceExpressionPromises ?? [])];
const unresolved = (row: {player:string; turn:number; fulfilled?:boolean; released?:boolean}) =>
row.player === actor && !row.fulfilled && !row.released;
if ([...bribes, ...alliances].some(row => unresolved(row) && row.turn !== g.turn)) return false;
// The joint turn-action reconciler still models alliances as pairs. Do not
// let it release another accepted commitment after this player joins a
// group until that planner can represent group membership and departure.
if (g.turnActionPromises && alliances.some(unresolved) &&
[g.bribePromises, g.bribeExpressionPromises, g.travelPromises, g.shipmentPromises,
g.movementPromises, g.movementExpressionPromises].some(rows => rows?.some(unresolved))) return false;
if (!bribes.some(row => unresolved(row) && row.answer) && !alliances.some(unresolved)) return true;
try {
const trial = structuredClone(g);
initializeCoalitionEscrow(trial);
if (!trial.coalitionState!.proposal) {
const result = coalitionRule(() => beginCoalitionAdmission(trial.players, trial.coalitionState, {
event:crypto.randomUUID(), nexus:crypto.randomUUID(), turn:trial.turn, groupId, entrant,
proposer:actor, expectedRevision:trial.coalitionState!.revision,
}));
applyCoalitionTransition(trial, result);
}
const original = trial.coalitionState!.proposal;
requireRule(original && original.groupId === groupId && original.entrant === entrant,
'The original group invitation changed.');
for (const voter of [...original.original.members, original.entrant]) {
const offer = trial.coalitionState!.proposal;
if (!offer || offer.approvals.includes(voter)) continue;
const result = coalitionRule(() => answerCoalitionAdmission(trial.players, trial.coalitionState, {
event:offer.event, nexus:offer.nexus, turn:offer.turn, actor:voter, accept:true,
expectedRevision:trial.coalitionState!.revision,
}));
applyCoalitionTransition(trial, result);
}
requireRule(!trial.coalitionState!.proposal, 'The proposed group did not finish.');
trial.truthtrance = null;
const player = getPlayer(trial, actor);
return (!hasBribePromises(trial, actor, true) || !!bribePromiseCompletion(trial, player)) &&
(!alliances.some(unresolved) || !!alliancePromisePlan(trial, player)) &&
(!publicDealCompletion(g, getPlayer(g, actor)) || publicDealCompletion(trial, player));
} catch (error) {
if (error instanceof RuleError) return false;
throw error;
}
}
/** Preserve legacy one-sided ally restrictions while recognizing every
* member of an explicitly staged larger alliance. */
function bribeAllied(g: Game, owner: Player, recipient: string): boolean {
@@ -16374,7 +16419,7 @@ function reconcileAlliancePromises(g: Game, voluntary?: { actor: string; action:
const ordered = orderedAllianceCommitments(g);
if (!ordered.some(promise => !promise.fulfilled && !promise.released)) return;
alliancePromiseIntegrity(g);
if (g.truthtrance || g.response || g.decision || g.phaseOpening || g.spiceWindow || g.spiceResolution ||
if (g.coalitionState?.proposal || g.truthtrance || g.response || g.decision || g.phaseOpening || g.spiceWindow || g.spiceResolution ||
g.pendingTreacheryDiscard || g.pendingKarama || g.pendingKull || g.pendingRicheseBetrayal) return;
const opportunity = allianceOpportunity(g), kept: AlliancePromise[] = [], expressions: AllianceExpressionPromise[] = [];
for (const promise of ordered) {
@@ -16431,7 +16476,7 @@ function projectedAlliancePromises(g: Game, player: string) {
}
function projectedAlliancePromiseGuide(g: Game, player: string): { action: Action } | null {
const opportunity = allianceOpportunity(g);
if (!opportunity || !allianceQuestionWindow(g) || g.truthtrance || g.ready.includes(player)) return null;
if (!opportunity || !allianceQuestionWindow(g) || g.coalitionState?.proposal || g.truthtrance || g.ready.includes(player)) return null;
const promise = orderedAllianceCommitments(g).find(record => record.player === player && record.turn === opportunity.turn &&
record.source === opportunity.source && !record.fulfilled && !record.released);
if (!promise) return null;
@@ -16745,7 +16790,7 @@ function ordinaryPaymentActionAvailable(g: Game, player: Player, action: Action)
action.amount > 0 && action.amount <= uncommittedSpice(g, player);
}
function projectedBribePromiseGuide(g: Game, player: string): { action: Action } | null {
if (!bribeQuestionWindow(g) || g.truthtrance) return null;
if (!bribeQuestionWindow(g) || g.coalitionState?.proposal || g.truthtrance) return null;
const joint = liveTurnActionPromises(g.turnActionPromises ?? [], player, g.turn).length
? turnActionCompletion(g, player) : null;
if (joint?.paymentsAfterPreparations && joint.cardPreparations?.length) return null;
@@ -44565,11 +44610,10 @@ function applyActionInner(
}
if (t === 'coalitionPropose' || t === 'coalitionAnswer' || t === 'coalitionLeave') {
requireRule(coalitionNexusAvailable(g), 'Group negotiations are unavailable in this Nexus.');
if (t === 'coalitionPropose' || t === 'coalitionAnswer' && action.accept !== false)
requireRule(!coalitionAdmissionBlocked(g),
'Resolve current bribe commitments before inviting an ally.');
const revision = integer(action.expectedRevision, 0, Number.MAX_SAFE_INTEGER, 'Coalition revision');
if (t === 'coalitionPropose') {
requireRule(coalitionAdmissionAllowed(g, id, stringField(action.groupId), stringField(action.entrant)),
'Honor your existing commitments before proposing this alliance.');
initializeCoalitionEscrow(g);
const result = coalitionRule(() => beginCoalitionAdmission(g.players, g.coalitionState, {
event: crypto.randomUUID(), nexus: crypto.randomUUID(), turn: g.turn,
@@ -44581,6 +44625,14 @@ function applyActionInner(
} else if (t === 'coalitionAnswer') {
requireRule(typeof action.accept === 'boolean', 'Accept or decline this group invitation.');
const offer = g.coalitionState!.proposal;
if (action.accept && offer) {
requireRule(coalitionAdmissionAllowed(g, id, offer.groupId, offer.entrant),
'Honor your existing commitments before accepting this alliance.');
if (offer.approvals.length + 1 === offer.original.members.length + 1)
for (const participant of offer.approvals)
requireRule(coalitionAdmissionAllowed(g, participant, offer.groupId, offer.entrant),
'An earlier approval no longer preserves its commitments.');
}
const result = coalitionRule(() => answerCoalitionAdmission(g.players, g.coalitionState, {
event: stringField(action.event), nexus: stringField(action.nexus),
turn: integer(action.turn, 1, Number.MAX_SAFE_INTEGER, 'Invitation turn'),
@@ -46866,15 +46918,25 @@ export function viewGame(state: Game, id: string, now?: number) {
// are not needed by board rules in the player view.
coalitionState: g.coalitionState ? {...g.coalitionState,
usedEvents:g.coalitionState.proposal ? [g.coalitionState.proposal.event] : []} : undefined,
coalitionNexus: coalitionNexusAvailable(g) ? {
me: id,
players: g.players.map(({id: seatId, name}) => ({id: seatId, name})),
groups: coalitionRule(() => coalitionGroups(g.players, g.coalitionState)),
proposal: g.coalitionState!.proposal,
revision: g.coalitionState!.revision,
...(coalitionAdmissionBlocked(g) ? {admissionBlocked:true} : {}),
...(g.coalitionState!.variant ? {variant: g.coalitionState!.variant} : {}),
} : null,
coalitionNexus: coalitionNexusAvailable(g) ? (() => {
const groups = coalitionRule(() => coalitionGroups(g.players, g.coalitionState));
const proposal = g.coalitionState!.proposal;
const occupied = new Set(groups.flatMap(group => group.members));
const limit = g.coalitionState!.variant === 'unlimited' ? 6 : 3;
const blockedInvitations = proposal ? [] : groups.flatMap(group => group.members.length >= limit ? [] :
g.players.filter(seat => !occupied.has(seat.id) &&
(seat.id === id || group.members.includes(id)) &&
!coalitionAdmissionAllowed(g, id, group.id, seat.id))
.map(seat => ({groupId:group.id, entrant:seat.id})));
const participants = proposal ? [...proposal.original.members, proposal.entrant] : [];
const acceptBlocked = !!proposal && participants.includes(id) && !proposal.approvals.includes(id) &&
!coalitionAdmissionAllowed(g, id, proposal.groupId, proposal.entrant);
return {me:id, players:g.players.map(({id:seatId,name}) => ({id:seatId,name})), groups, proposal,
revision:g.coalitionState!.revision,
...(blockedInvitations.length ? {blockedInvitations} : {}),
...(acceptBlocked ? {acceptBlocked:true} : {}),
...(g.coalitionState!.variant ? {variant:g.coalitionState!.variant} : {})};
})() : null,
homeworldAllianceBlocks: Object.fromEntries(g.players.filter((p) => p.id !== id)
.flatMap((p) => { const reason = homeworldAllianceReason(g, id, p.id); return reason ? [[p.id, reason]] : []; })),
ecazSpice: