Allow group admission with compatible commitments
Verify and publish checkpoint container / container (push) Canceled after 0s
Verify and publish checkpoint container / container (push) Canceled after 0s
This commit is contained in:
1 parent
0faa7923b0
commit
6524ed3bf2
14 files changed
+679
-53
No files matched your search
+13
-10
@@ -1321,6 +1321,16 @@ function policyActions(g: GameView): Action[] {
|
||||
? [{ type: 'decision', event: search.event, card: ordered[0].id }]
|
||||
: [];
|
||||
}
|
||||
const pendingGroup = g.coalitionNexus;
|
||||
if (pendingGroup?.proposal) {
|
||||
const proposal = pendingGroup.proposal;
|
||||
const participants = [...proposal.original.members, proposal.entrant];
|
||||
return participants.includes(me.id) && !proposal.approvals.includes(me.id)
|
||||
? [{type: 'coalitionAnswer', event: proposal.event, nexus: proposal.nexus,
|
||||
turn: proposal.turn, accept: !pendingGroup.acceptBlocked,
|
||||
expectedRevision: pendingGroup.revision}]
|
||||
: [];
|
||||
}
|
||||
if (g.truthCharityGuide) return [g.truthCharityGuide.action];
|
||||
if (g.bribePromiseGuide) return [g.bribePromiseGuide.action];
|
||||
if (g.alliancePromiseGuide) return [g.alliancePromiseGuide.action];
|
||||
@@ -3184,14 +3194,6 @@ function policyActions(g: GameView): Action[] {
|
||||
}
|
||||
if (g.phase === 1) {
|
||||
const group = g.coalitionNexus;
|
||||
if (group?.proposal) {
|
||||
const proposal = group.proposal;
|
||||
const participants = [...proposal.original.members, proposal.entrant];
|
||||
return participants.includes(me.id) && !proposal.approvals.includes(me.id)
|
||||
? [{type: 'coalitionAnswer', event: proposal.event, nexus: proposal.nexus,
|
||||
turn: proposal.turn, accept: !group.admissionBlocked, expectedRevision: group.revision}]
|
||||
: [];
|
||||
}
|
||||
const thumper = me.hand?.find((c) => c.effect === 'thumper');
|
||||
const prior = g.spiceDiscardTop[0];
|
||||
if (
|
||||
@@ -3226,9 +3228,10 @@ function policyActions(g: GameView): Action[] {
|
||||
const card = me.hand?.find((c) => c.effect === 'harvester');
|
||||
if (card) return [{ type: 'card', card: card.id }];
|
||||
}
|
||||
if (g.nexus && !g.spiceWindow && !g.ready.includes(me.id) && group?.variant === 'unlimited' && !group.admissionBlocked &&
|
||||
if (g.nexus && !g.spiceWindow && !g.ready.includes(me.id) && group?.variant === 'unlimited' &&
|
||||
!group.groups.some(alliance => alliance.members.includes(me.id))) {
|
||||
const alliance = group.groups.find(candidate => candidate.members.length >= 2 && candidate.members.length < 6);
|
||||
const alliance = group.groups.find(candidate => candidate.members.length >= 2 && candidate.members.length < 6 &&
|
||||
!group.blockedInvitations?.some(blocked => blocked.groupId === candidate.id && blocked.entrant === me.id));
|
||||
if (alliance) return [{type:'coalitionPropose', groupId:alliance.id, entrant:me.id,
|
||||
expectedRevision:group.revision}];
|
||||
}
|
||||
|
||||
+80
-18
@@ -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:
|
||||
|
||||
+1
-1
@@ -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, four-member unlimited admission, funding and victory, staged larger-group faction helpers and alliance Truthtrance, funding with outsider-only commitments, group departures with live bribes, Guild transport, Emperor and Fremen aid, worm protection, battle preparation and allied traitor votes; complete group play and combinations remain open.', evidence: ['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, 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'] },
|
||||
],
|
||||
related: [
|
||||
'mentat',
|
||||
|
||||
Reference in new issue
Block a user