diff --git a/components/alliance-promises.tsx b/components/alliance-promises.tsx index c65719a8..d427f32c 100644 --- a/components/alliance-promises.tsx +++ b/components/alliance-promises.tsx @@ -2,15 +2,25 @@ import type { Action, GameView } from '@/game/engine'; import { allianceClaimText, alliancePromiseModeSupported } from '@/game/alliance-promises'; -import { evaluateAllianceExpression } from '@/game/alliance-expression-promises'; +import { evaluateAllianceExpression, type AllianceExpression } from '@/game/alliance-expression-promises'; +import { coalitionAllies, coalitionOf } from '@/game/coalition-alliance'; import { quoteNexusAlliance } from '@/game/nexus-alliance'; import { Button } from './ui/button'; type AlliancePromiseProps = { game: GameView; busy: boolean; send: (action: Action) => void }; const publicContext = (game: GameView) => ({ - players: game.players.map(player => ({ id: player.id, ally: player.ally })), offers: game.allianceOffers, + players: game.players.map(player => ({ id: player.id, ally: player.ally, allySinceTurn: player.allySinceTurn })), + offers: game.allianceOffers, coalitionState: game.coalitionState, }); +/** Group membership is public. Evaluate only pure expressions here: mixed + * questions retain their privately compiled facts on the server. */ +function expressionMatchesMembers(expression: AllianceExpression, allies: readonly string[]): boolean { + if (expression.kind === 'partner') return allies.includes(expression.partner); + return expression.kind === 'and' ? expression.terms.every(term => expressionMatchesMembers(term, allies)) + : expression.terms.some(term => expressionMatchesMembers(term, allies)); +} + /** A guide may end a pair, accept real consent or confirm current membership. * It never sends a proposal on another player's behalf. */ export function AlliancePromiseGuideButton(props: AlliancePromiseProps) { @@ -29,15 +39,27 @@ export function AlliancePromiseGuideButton(props: AlliancePromiseProps) { !promise.fulfilled && !promise.released); // Pure membership is visible. The server alone knows a mixed question's // frozen facts and reduced future obligation; never reconstruct it here. - const matches = (ally: string | null) => live.every(promise => (ally === promise.partner) === promise.answer) && - pure.every(promise => evaluateAllianceExpression(promise.claim, ally) === promise.answer); - let ownedAction = action.type === 'ready' && !!me && matches(me.ally); + const matches = (allies: readonly string[]) => live.every(promise => allies.includes(promise.partner) === promise.answer) && + pure.every(promise => (allies.length <= 1 ? evaluateAllianceExpression(promise.claim, allies[0] ?? null) + : expressionMatchesMembers(promise.claim, allies)) === promise.answer); + let group: ReturnType = null, allies = me?.ally ? [me.ally] : []; + try { + if (current.coalitionState && me) { + group = coalitionOf(current.players, current.coalitionState, actor); + allies = coalitionAllies(current.players, current.coalitionState, actor); + } + } catch { return false; } + let ownedAction = action.type === 'ready' && !!me && matches(allies); + if (action.type === 'coalitionLeave') + ownedAction = !!group && group.members.length >= 3 && !!current.coalitionState && + current.coalitionState.proposal === null && action.expectedRevision === current.coalitionState.revision && matches([]); if (action.type === 'alliance') { - if (action.target === null) ownedAction = true; + if ((group?.members.length ?? 0) >= 3) ownedAction = false; + else if (action.target === null) ownedAction = true; else if (typeof action.target === 'string' && action.target !== actor && current.players.some(player => player.id === action.target)) { try { const quote = quoteNexusAlliance({ ...publicContext(current), actor, target: action.target }); - ownedAction = !!quote.formed && matches(quote.allies[actor]); + ownedAction = !!quote.formed && matches(quote.allies[actor] ? [quote.allies[actor]!] : []); } catch { ownedAction = false; } } } @@ -55,6 +77,7 @@ export function AlliancePromiseGuideButton(props: AlliancePromiseProps) { return