Support group departures in joint commitment plans
Verify and publish checkpoint container / container (push) Successful in 11m20s

This commit is contained in:
Quintine committed 2026-10-29 01:01:51 +11:00
1 parent 6524ed3bf2
commit 52d932002b
12 files changed
+552 -39

No files matched your search

+47 -13
View File
@@ -16063,12 +16063,6 @@ function coalitionAdmissionAllowed(g: Game, actor: string, groupId: string, entr
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);
@@ -16095,6 +16089,10 @@ function coalitionAdmissionAllowed(g: Game, actor: string, groupId: string, entr
requireRule(!trial.coalitionState!.proposal, 'The proposed group did not finish.');
trial.truthtrance = null;
const player = getPlayer(trial, actor);
const joint = !!g.turnActionPromises && alliances.some(unresolved) &&
[g.bribePromises, g.bribeExpressionPromises, g.travelPromises, g.shipmentPromises,
g.movementPromises, g.movementExpressionPromises].some(rows => rows?.some(unresolved));
if (joint && !turnActionCompletion(trial, actor)) return false;
return (!hasBribePromises(trial, actor, true) || !!bribePromiseCompletion(trial, player)) &&
(!alliances.some(unresolved) || !!alliancePromisePlan(trial, player)) &&
(!publicDealCompletion(g, getPlayer(g, actor)) || publicDealCompletion(trial, player));
@@ -21663,8 +21661,10 @@ function initializeTurnActionOrder(g: Game) {
}
/** All admissible final native partners use the original reciprocal quote.
* Exact outcome rows live only in this witness; no route is persisted. */
type TurnActionAllianceRoute = { steps: { player: string; target: string | null }[]; uncertain: boolean };
function turnActionAlliancePlans(g: Game, actor: string, includeUnknown = false): (TurnActionAllianceRoute | undefined)[] {
type TurnActionAllianceRoute = { steps: ({ player: string; target: string | null; kind?: 'pair' } |
{ player: string; kind: 'groupLeave'; revision: number })[]; uncertain: boolean };
function turnActionAlliancePlans(g: Game, actor: string, includeUnknown = false,
allowGroupLeave = true): (TurnActionAllianceRoute | undefined)[] {
const scalars = g.alliancePromises ?? [], groups = g.allianceExpressionPromises ?? [];
const active = [...scalars, ...groups].some(promise => promise.player === actor &&
promise.turn === g.turn && !promise.fulfilled && !promise.released);
@@ -21673,6 +21673,21 @@ function turnActionAlliancePlans(g: Game, actor: string, includeUnknown = false)
if (!opportunity) return [];
if ([...scalars, ...groups].some(promise => promise.player === actor && promise.turn === g.turn &&
!promise.fulfilled && !promise.released && promise.source !== opportunity.source)) return [];
const member = getPlayer(g, actor);
const coalition = g.coalitionState && coalitionRule(() => coalitionOf(g.players, g.coalitionState, actor));
if (coalition && coalition.members.length >= 3) {
if (groupAllianceAnswersMatch(g, member, opportunity, scalars, groups))
return [{steps: [], uncertain: false}];
if (g.coalitionState!.proposal || !allowGroupLeave) return [];
const trial = structuredClone(g);
try {
applyCoalitionTransition(trial, coalitionRule(() => leaveCoalition(trial.players, trial.coalitionState,
{actor, expectedRevision:trial.coalitionState!.revision})));
return groupAllianceAnswersMatch(trial, getPlayer(trial, actor), opportunity, scalars, groups)
? [{steps: [{player: actor, kind: 'groupLeave', revision: g.coalitionState!.revision}], uncertain: false}]
: [];
} catch (error) { if (error instanceof RuleError) return []; throw error; }
}
const outcomes = allianceExpressionOutcomes(scalars, groups, actor, opportunity,
{ players: g.players, offers: g.allianceOffers });
const plans: TurnActionAllianceRoute[] = [], seen = new Set<string>();
@@ -21964,7 +21979,8 @@ function turnActionCompletionAfterFreshDiscard(source: Game, actor: string, unkn
const power = turnActionPowerCompletion(trial, actor,
leaves.filter((leaf): leaf is TurnActionPowerRequirement => leaf.kind === 'battlePower'));
if (!power) return null;
const alliancePlans = turnActionAlliancePlans(trial, actor, !!unknownConsent);
const alliancePlans = turnActionAlliancePlans(trial, actor, !!unknownConsent,
!unsettledPromises(source.turnActionPromises));
for (const required of bribePromiseRule(() => bribeCompletionCandidates(
trial.bribePromises!, trial.bribeExpressionPromises ?? [], actor, source.turn))) {
// A quoted payment follows only a currently owned modal continuation;
@@ -21977,12 +21993,28 @@ function turnActionCompletionAfterFreshDiscard(source: Game, actor: string, unkn
if (required.length && bribeTimingBlock(paymentWindow)) continue;
const total = required.reduce((sum, payment) => sum + payment.amount, 0);
if (!Number.isSafeInteger(total) ||
required.some(payment => payment.recipient === actor || bribeAllied(trial, owner, payment.recipient) ||
required.some(payment => payment.recipient === actor ||
!trial.players.some(player => player.id === payment.recipient))) continue;
for (const alliancePlan of alliancePlans) {
try {
const departure = alliancePlan?.steps.find(step => step.kind === 'groupLeave');
const leaveFirst = required.some(payment => bribeAllied(trial, owner, payment.recipient));
if (leaveFirst && !departure) continue;
const paymentSource = leaveFirst ? structuredClone(trial) : trial;
if (leaveFirst && departure) {
applyCoalitionTransition(paymentSource, coalitionRule(() => leaveCoalition(
paymentSource.players, paymentSource.coalitionState,
{actor:departure.player, expectedRevision:departure.revision})));
if (required.some(payment => bribeAllied(paymentSource, getPlayer(paymentSource, actor), payment.recipient))) continue;
}
const prepareAlliance = (paid: Game) => {
for (const action of alliancePlan?.steps ?? []) {
if (action.kind === 'groupLeave') {
if (leaveFirst) continue;
applyCoalitionTransition(paid, coalitionRule(() => leaveCoalition(paid.players, paid.coalitionState,
{actor:action.player, expectedRevision:action.revision})));
continue;
}
const quote = normalNexusAllianceQuote(paid, getPlayer(paid, action.player), action.target);
for (const player of paid.players) player.ally = quote.allies[player.id];
paid.allianceOffers = quote.offers;
@@ -22000,7 +22032,7 @@ function turnActionCompletionAfterFreshDiscard(source: Game, actor: string, unkn
}
};
const voluntary = { actor, action: { type: 'bribe' } };
for (const cardSeed of turnActionFundedPreparations(trial, actor, required, cardRequirements,
for (const cardSeed of turnActionFundedPreparations(paymentSource, actor, required, cardRequirements,
source.phaseOpening ? windowRequirements.filter(required => required.effect !== 'amal') : windowRequirements, prepareAlliance)) {
const funded = cardSeed.game, payer = getPlayer(funded, actor);
try {
@@ -22051,7 +22083,9 @@ function turnActionCompletionAfterFreshDiscard(source: Game, actor: string, unkn
...(cardSeed.cardsFirst ? { paymentsAfterPreparations: true } : {}), ...(bid ? { bid } : {}), ...(revival ? { revival } : {}),
...(battlePlan ? { battlePlan } : {}), ...(traitor ? { traitor } : {}),
...(power.action ? { battlePower: power.action } : {}),
...(alliancePlan ? { alliance: alliancePlan.steps.map(step => ({ type: 'alliance', target: step.target })) } : {}),
...(alliancePlan ? { alliance: alliancePlan.steps.map(step => step.kind === 'groupLeave'
? {type: 'coalitionLeave', expectedRevision: step.revision}
: { type: 'alliance', target: step.target }) } : {}),
payments: cardSeed.payments };
}
} catch (error) { if (!(error instanceof RuleError)) throw error; }
@@ -22259,7 +22293,7 @@ function reconcileTurnActionPromises(g: Game, actor?: string, action?: Action, s
requireRule(actor !== promise.player || !promise.answer, 'You cannot voluntarily evade your Truthtrance bribe answer.');
if (promise.answer) promise.released = true; else promise.fulfilled = true;
}
if (!turnActionPromiseTurn(g) || g.truthtrance) return;
if (!turnActionPromiseTurn(g) || g.truthtrance || g.coalitionState?.proposal) return;
const nexus = allianceOpportunity(g);
if (nexus && action?.type === 'bribe' && g.turnActionPromises!.some(promise =>
promise.player === actor && promise.turn === g.turn && promise.alliance?.source === nexus.source))
+1 -1
View File
@@ -4505,7 +4505,7 @@ export const RULE_TOPICS: RuleTopic[] = [
{ area: 'Player controls', status: 'Partial', detail: 'The lobby offers the pair option. A staged group Emperor can choose either ally for spice and extra revival; group games cannot yet be started from the lobby.', evidence: ['components/game-table.tsx', 'components/coalition-emperor-aid.tsx', 'tests/short-alliance-victory-controls.test.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'Legal alliance and victory paths use the shared target; staged group Emperor bots can offer a targeted revival. Strategic tuning remains deferred.', evidence: ['game/bots.ts', 'game/victory-progress.ts'] },
{ area: 'Documentation', status: 'Partial', detail: 'The printed option and provisional two-controller precedence are recorded.', evidence: ['docs/OPTIONAL_ALLIANCE_VICTORY.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Focused pair and staged group evidence includes real Nexus actions, admission with live bribe and alliance answers, four-member unlimited admission, funding and victory, staged larger-group faction helpers, funding with outsider-only commitments, Guild transport, Emperor and Fremen aid, worm protection, battle preparation and allied traitor votes; complete group play and combinations remain open.', evidence: ['tests/coalition-bribe-admission.test.ts', 'tests/coalition-admission-deal-truth.test.ts', 'tests/coalition-admission-joint-commitments.test.ts', 'tests/coalition-alliance-admission-commitments.test.ts', 'tests/coalition-admission-bribe-controls.test.tsx', 'tests/alliance-victory-option.test.ts', 'tests/short-alliance-victory-runtime.test.ts', 'tests/coalition-unlimited-engine.test.ts', 'tests/coalition-unlimited-powers.test.ts', 'tests/coalition-unlimited-emperor.test.ts', 'tests/coalition-truth-alliance.test.ts', 'tests/coalition-truth-alliance-controls.test.tsx', 'tests/coalition-truth-bribe-targets.test.ts', 'tests/coalition-bribe-departure.test.tsx', 'tests/coalition-bribe-alliance-guides.test.ts', 'tests/coalition-outsider-commitment-funding.test.ts', 'tests/coalition-unlimited-worm-engine.test.ts', 'tests/coalition-nexus-engine.test.ts', 'tests/coalition-shipment-funding.test.ts', 'tests/coalition-bid-funding.test.ts', 'tests/coalition-guild-shipment.test.ts', 'tests/coalition-guild-transport.test.ts', 'tests/coalition-fremen-worm.test.ts', 'tests/coalition-emperor-aid.test.ts', 'tests/coalition-fremen-revival.test.ts', 'tests/coalition-battle-preparation.test.ts', 'tests/coalition-battle-power-question.test.ts', 'tests/coalition-harkonnen-traitor.test.ts', 'tests/coalition-traitor-declarations.test.ts'] },
{ area: 'Verification', status: 'Partial', detail: 'Focused pair and staged group evidence includes real Nexus actions, admission with live bribe and alliance answers including matching joint commitments, four-member unlimited admission, funding and victory, staged larger-group faction helpers, funding with outsider-only commitments, Guild transport, Emperor and Fremen aid, worm protection, battle preparation and allied traitor votes; complete group play and combinations remain open.', evidence: ['tests/coalition-bribe-admission.test.ts', 'tests/coalition-admission-deal-truth.test.ts', 'tests/coalition-admission-joint-commitments.test.ts', 'tests/coalition-admission-joint-stay.test.ts', 'tests/coalition-admission-joint-leave.test.ts', 'tests/coalition-pending-joint-leave.test.ts', 'tests/coalition-joint-leave-payment-order.test.ts', 'tests/coalition-whole-turn-leave-guard.test.ts', 'tests/coalition-alliance-admission-commitments.test.ts', 'tests/coalition-admission-bribe-controls.test.tsx', 'tests/alliance-victory-option.test.ts', 'tests/short-alliance-victory-runtime.test.ts', 'tests/coalition-unlimited-engine.test.ts', 'tests/coalition-unlimited-powers.test.ts', 'tests/coalition-unlimited-emperor.test.ts', 'tests/coalition-truth-alliance.test.ts', 'tests/coalition-truth-alliance-controls.test.tsx', 'tests/coalition-truth-bribe-targets.test.ts', 'tests/coalition-bribe-departure.test.tsx', 'tests/coalition-bribe-alliance-guides.test.ts', 'tests/coalition-outsider-commitment-funding.test.ts', 'tests/coalition-unlimited-worm-engine.test.ts', 'tests/coalition-nexus-engine.test.ts', 'tests/coalition-shipment-funding.test.ts', 'tests/coalition-bid-funding.test.ts', 'tests/coalition-guild-shipment.test.ts', 'tests/coalition-guild-transport.test.ts', 'tests/coalition-fremen-worm.test.ts', 'tests/coalition-emperor-aid.test.ts', 'tests/coalition-fremen-revival.test.ts', 'tests/coalition-battle-preparation.test.ts', 'tests/coalition-battle-power-question.test.ts', 'tests/coalition-harkonnen-traitor.test.ts', 'tests/coalition-traitor-declarations.test.ts'] },
],
related: [
'mentat',