'use client'; import type { CoalitionAdmission, CoalitionGroup } from '../game/coalition-alliance'; /** Public Nexus projection. The authoritative engine still checks all timing, * eligibility, event and revision constraints before changing the alliance. */ export type CoalitionNexusView = { me: string; players: readonly { id: string; name: string }[]; groups: readonly CoalitionGroup[]; proposal: CoalitionAdmission | null; revision: number; /** Absent preserves the original, at-most-three alliance prototype. */ variant?: 'three-short' | 'unlimited'; /** Until group admission can reconcile a live bribe commitment, only * departure remains available in this Nexus. */ admissionBlocked?: boolean; /** Invitation choices that this actor cannot make while commitments apply. */ blockedInvitations?: readonly { groupId: string; entrant: string }[]; /** Whether this actor is unable to accept the pending invitation. */ acceptBlocked?: boolean; }; export type CoalitionNexusIntent = | { kind: 'propose'; groupId: string; entrant: string; expectedRevision: number } | { kind: 'answer'; event: string; nexus: string; turn: number; accept: boolean; expectedRevision: number } | { kind: 'leave'; expectedRevision: number }; /** Choices are informational; the server remains the legality authority. */ export function coalitionNexusOptions(view: CoalitionNexusView): { invitations: { groupId: string; entrant: string; members: readonly string[] }[]; mayLeave: boolean; mayAccept: boolean; mayDecline: boolean; } { const seated = view.players.some(player => player.id === view.me); const ownGroup = view.groups.find(group => group.members.includes(view.me)); const occupied = new Set(view.groups.flatMap(group => group.members)); const invitations = !seated || view.proposal || view.admissionBlocked ? [] : view.groups.flatMap(group => (group.members.length < 2 || group.members.length >= (view.variant === 'unlimited' ? 6 : 3)) ? [] : view.players .filter(player => !occupied.has(player.id) && (player.id === view.me || group.members.includes(view.me)) && !view.blockedInvitations?.some(blocked => blocked.groupId === group.id && blocked.entrant === player.id)) .map(player => ({ groupId: group.id, entrant: player.id, members: group.members }))); const participants = view.proposal ? [...view.proposal.original.members, view.proposal.entrant] : []; const mayDecline = seated && participants.includes(view.me); return { invitations, mayLeave: seated && !!ownGroup && !view.proposal, mayAccept: mayDecline && !view.admissionBlocked && !view.acceptBlocked && !view.proposal!.approvals.includes(view.me), mayDecline, }; } /** Render only when the group-alliance option and Nexus are authorized. */ export function CoalitionNexus({ view, disabled = false, onIntent }: { view: CoalitionNexusView; disabled?: boolean; onIntent: (intent: CoalitionNexusIntent) => void; }) { const options = coalitionNexusOptions(view); const name = (id: string) => view.players.find(player => player.id === id)?.name ?? id; const names = (ids: readonly string[]) => ids.map(name).join(', '); const proposal = view.proposal; const ownGroup = view.groups.find(group => group.members.includes(view.me)); const submit = (intent: CoalitionNexusIntent) => { if (!disabled) onIntent(intent); }; return

Group alliance negotiations

{view.variant === 'three-short' &&

Alliances can have up to three members and need three strongholds to win.

} {view.variant === 'unlimited' ?

Every current member and the invited player must agree. The existing alliance remains in place while an invitation is pending.

:

All three players must agree before an invitation forms an alliance. The existing pair remains allied while an invitation is pending.

} {view.groups.length ? :

There are no alliances yet.

} {proposal ?
{view.admissionBlocked &&

Acceptance is unavailable while a bribe commitment remains unresolved. You may decline this invitation.

} {!view.admissionBlocked && view.acceptBlocked &&

Acceptance is unavailable while it would conflict with your commitments. You may decline this invitation.

}

{name(proposal.proposer)} proposes adding {name(proposal.entrant)} to {names(proposal.original.members)}.

Approved: {names(proposal.approvals)}. Waiting for: {names( [...proposal.original.members, proposal.entrant].filter(id => !proposal.approvals.includes(id)), ) || 'no one'}.

{options.mayAccept && } {options.mayDecline && }
:
{view.admissionBlocked &&

Invitations are unavailable while a bribe commitment remains unresolved. Finish the commitment or wait until its binding period ends.

} {!view.admissionBlocked && !!view.blockedInvitations?.length &&

Some invitations are unavailable while they would conflict with your commitments.

} {options.invitations.length > 0 &&

{view.variant === 'unlimited' ? 'Invite an unallied player to an existing alliance, or ask to join one.' : 'Invite an unallied player to an existing pair, or ask to join a pair.'}

} {options.invitations.map(option => )} {options.mayLeave && } {ownGroup?.members.length === 3 &&

Leaving keeps the other two players allied under the provisional online policy.

} {view.variant === 'unlimited' && (ownGroup?.members.length ?? 0) > 3 &&

Leaving keeps the other players allied under the provisional online policy.

}
}
; }