Files
Dune/components/coalition-nexus.tsx
Quintine 6524ed3bf2
Verify and publish checkpoint container / container (push) Canceled after 0s
Allow group admission with compatible commitments
2026-10-29 00:54:36 +11:00

116 lines
6.9 KiB
TypeScript

'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?: '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 <section aria-label="Group alliance negotiations" className="notice min-w-0 space-y-3 break-words">
<h3>Group alliance negotiations</h3>
{view.variant === 'unlimited' ? <p>Every current member and the invited player must agree.
The existing alliance remains in place while an invitation is pending.</p> :
<p>All three players must agree before an invitation forms an alliance.
The existing pair remains allied while an invitation is pending.</p>}
{view.groups.length ? <ul>{view.groups.map(group =>
<li key={group.id}>{names(group.members)}</li>)}</ul> : <p>There are no alliances yet.</p>}
{proposal ? <div aria-live="polite">
{view.admissionBlocked && <p>Acceptance is unavailable while a bribe commitment remains unresolved. You may decline this invitation.</p>}
{!view.admissionBlocked && view.acceptBlocked && <p>Acceptance is unavailable while it would conflict with your commitments. You may decline this invitation.</p>}
<p>{name(proposal.proposer)} proposes adding {name(proposal.entrant)} to {names(proposal.original.members)}.</p>
<p>Approved: {names(proposal.approvals)}. Waiting for: {names(
[...proposal.original.members, proposal.entrant].filter(id => !proposal.approvals.includes(id)),
) || 'no one'}.</p>
{options.mayAccept && <button className="game-action" disabled={disabled} onClick={() => submit({
kind: 'answer', event: proposal.event, nexus: proposal.nexus, turn: proposal.turn,
accept: true, expectedRevision: view.revision,
})}>Accept invitation</button>}
{options.mayDecline && <button className="game-action" disabled={disabled} onClick={() => submit({
kind: 'answer', event: proposal.event, nexus: proposal.nexus, turn: proposal.turn,
accept: false, expectedRevision: view.revision,
})}>{proposal.proposer === view.me ? 'Withdraw and cancel invitation' :
proposal.approvals.includes(view.me) ? 'Withdraw approval and cancel invitation' :
'Decline and cancel invitation'}</button>}
</div> : <div>
{view.admissionBlocked && <p>Invitations are unavailable while a bribe commitment remains unresolved.
Finish the commitment or wait until its binding period ends.</p>}
{!view.admissionBlocked && !!view.blockedInvitations?.length && <p>Some invitations are unavailable while they would conflict with your commitments.</p>}
{options.invitations.length > 0 && <p>{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.'}</p>}
{options.invitations.map(option => <button className="game-action" disabled={disabled}
key={JSON.stringify([option.groupId, option.entrant])} onClick={() => submit({
kind: 'propose', groupId: option.groupId, entrant: option.entrant, expectedRevision: view.revision,
})}>{option.entrant === view.me ? `Ask to join ${names(option.members)}` :
`Invite ${name(option.entrant)} to ${names(option.members)}`}</button>)}
{options.mayLeave && <button className="game-action" disabled={disabled} onClick={() => submit({
kind: 'leave', expectedRevision: view.revision,
})}>Leave {names(ownGroup!.members)}</button>}
{ownGroup?.members.length === 3 && <p>Leaving keeps the other two players allied under the provisional online policy.</p>}
{view.variant === 'unlimited' && (ownGroup?.members.length ?? 0) > 3 &&
<p>Leaving keeps the other players allied under the provisional online policy.</p>}
</div>}
</section>;
}