feat(ui): consent dialog for modifier profile proposals
This commit is contained in:
parent
ebed10d39a
commit
8f5dca9c21
4 changed files with 238 additions and 3 deletions
|
|
@ -34,8 +34,9 @@ import type { PieceType } from '../schema';
|
|||
import type { GameResult } from '../engine';
|
||||
import { GameClient } from '../net/client';
|
||||
import { PredictionManager } from '../net/prediction';
|
||||
import type { Color, PresetActivation, PromotionPiece } from '../net/types';
|
||||
import type { Color, PresetActivation, PromotionPiece, ModifierProfileWire, ModifierProfileProposalPendingPayload } from '../net/types';
|
||||
import * as audio from '../audio';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
const WS_URL =
|
||||
(import.meta as { env?: Record<string, string> }).env?.['VITE_WS_URL'] ??
|
||||
|
|
@ -53,6 +54,10 @@ export interface MultiplayerGameState {
|
|||
/** Room error surfaced to the UI (e.g. ROOM_NOT_FOUND on reconnect
|
||||
* after grace expiry). Null when healthy. */
|
||||
error: string | null;
|
||||
// Modifier proposal state
|
||||
modifierProposal: { profile: ModifierProfileWire; expiresAt: number; proposer: Color } | null;
|
||||
modifierRejectionMessage: string | null;
|
||||
isProposer: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -71,6 +76,9 @@ export function useMultiplayerGame(code: string, token: string) {
|
|||
myColor: null,
|
||||
loading: true,
|
||||
error: null,
|
||||
modifierProposal: null,
|
||||
modifierRejectionMessage: null,
|
||||
isProposer: false,
|
||||
});
|
||||
|
||||
// Mount the connection exactly once per code/token pair.
|
||||
|
|
@ -105,6 +113,32 @@ export function useMultiplayerGame(code: string, token: string) {
|
|||
// The first game.state snapshot after (re)connect clears `loading`.
|
||||
setMeta((m) => ({ ...m, loading: false }));
|
||||
};
|
||||
const onProposalPending = (e: { payload: ModifierProfileProposalPendingPayload }) => {
|
||||
setMeta((m) => ({
|
||||
...m,
|
||||
modifierProposal: { profile: e.payload.profile, expiresAt: e.payload.expiresAt, proposer: e.payload.proposer },
|
||||
modifierRejectionMessage: null,
|
||||
isProposer: false,
|
||||
}));
|
||||
};
|
||||
const onProposalRejected = (e: { payload: { reason: string } }) => {
|
||||
setMeta((m) => ({
|
||||
...m,
|
||||
modifierProposal: null,
|
||||
modifierRejectionMessage: e.payload.reason,
|
||||
isProposer: false,
|
||||
}));
|
||||
setTimeout(() => {
|
||||
setMeta((m) => m.modifierRejectionMessage === e.payload.reason ? { ...m, modifierRejectionMessage: null } : m);
|
||||
}, 3000);
|
||||
};
|
||||
const onProposalConsentReceived = () => {
|
||||
setMeta((m) => ({ ...m, modifierProposal: null, isProposer: false }));
|
||||
toast.success("Opponent approved your profile change. It will take effect next turn.");
|
||||
};
|
||||
const onProposalQueued = () => {
|
||||
setMeta((m) => ({ ...m, isProposer: true }));
|
||||
};
|
||||
const onGameDelta = (e: {
|
||||
payload: {
|
||||
gameOver: { winner: string; reason: string } | null;
|
||||
|
|
@ -148,6 +182,10 @@ export function useMultiplayerGame(code: string, token: string) {
|
|||
client.on('room.joined', onJoined);
|
||||
client.on('game.state', onGameState);
|
||||
client.on('game.delta', onGameDelta);
|
||||
client.on('modifier-profile.proposal-pending', onProposalPending);
|
||||
client.on('modifier-profile.rejected', onProposalRejected);
|
||||
client.on('modifier-profile.consent-received', onProposalConsentReceived);
|
||||
client.on('modifier-profile.queued', onProposalQueued);
|
||||
client.on('error', onError);
|
||||
|
||||
client.connect(code, token).catch((err: unknown) => {
|
||||
|
|
@ -166,6 +204,10 @@ export function useMultiplayerGame(code: string, token: string) {
|
|||
client.off('room.joined', onJoined);
|
||||
client.off('game.state', onGameState);
|
||||
client.off('game.delta', onGameDelta);
|
||||
client.off('modifier-profile.proposal-pending', onProposalPending);
|
||||
client.off('modifier-profile.rejected', onProposalRejected);
|
||||
client.off('modifier-profile.consent-received', onProposalConsentReceived);
|
||||
client.off('modifier-profile.queued', onProposalQueued);
|
||||
client.off('error', onError);
|
||||
client.close();
|
||||
clientRef.current = null;
|
||||
|
|
@ -277,5 +319,14 @@ export function useMultiplayerGame(code: string, token: string) {
|
|||
connected: meta.connected,
|
||||
loading: meta.loading,
|
||||
error: meta.error,
|
||||
modifierProposal: meta.modifierProposal,
|
||||
modifierRejectionMessage: meta.modifierRejectionMessage,
|
||||
isProposer: meta.isProposer,
|
||||
sendConsent: (decision: 'approve' | 'reject') => {
|
||||
clientRef.current?.send({
|
||||
type: 'modifier-profile.consent',
|
||||
payload: { roomCode: code, decision }
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,10 @@ import type {
|
|||
GameMovePayload,
|
||||
GamePresetsPayload,
|
||||
GameStatePayload,
|
||||
ModifierProfileProposalPendingPayload,
|
||||
ModifierProfileRejectedPayload,
|
||||
ModifierProfileConsentReceivedPayload,
|
||||
ModifierProfileQueuedPayload,
|
||||
PresetActivation,
|
||||
PromotionPiece,
|
||||
RoomCreatedPayload,
|
||||
|
|
@ -36,6 +40,10 @@ export type GameClientEvent =
|
|||
| { type: "game.presets"; payload: GamePresetsPayload }
|
||||
| { type: "room.created"; payload: RoomCreatedPayload }
|
||||
| { type: "room.joined"; payload: RoomJoinedPayload }
|
||||
| { type: "modifier-profile.proposal-pending"; payload: ModifierProfileProposalPendingPayload }
|
||||
| { type: "modifier-profile.rejected"; payload: ModifierProfileRejectedPayload }
|
||||
| { type: "modifier-profile.consent-received"; payload: ModifierProfileConsentReceivedPayload }
|
||||
| { type: "modifier-profile.queued"; payload: ModifierProfileQueuedPayload }
|
||||
| { type: "error"; payload: ErrorPayload }
|
||||
| { type: "connected" }
|
||||
| { type: "disconnected"; willReconnect: boolean };
|
||||
|
|
@ -438,6 +446,18 @@ export class GameClient {
|
|||
case "room.joined":
|
||||
this.emit({ type, payload: payload as RoomJoinedPayload });
|
||||
return;
|
||||
case "modifier-profile.proposal-pending":
|
||||
this.emit({ type, payload: payload as ModifierProfileProposalPendingPayload });
|
||||
return;
|
||||
case "modifier-profile.rejected":
|
||||
this.emit({ type, payload: payload as ModifierProfileRejectedPayload });
|
||||
return;
|
||||
case "modifier-profile.consent-received":
|
||||
this.emit({ type, payload: payload as ModifierProfileConsentReceivedPayload });
|
||||
return;
|
||||
case "modifier-profile.queued":
|
||||
this.emit({ type, payload: payload as ModifierProfileQueuedPayload });
|
||||
return;
|
||||
case "error":
|
||||
this.emit({ type, payload: payload as ErrorPayload });
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -2,10 +2,11 @@ import { Board } from './Board';
|
|||
import { RulesDrawer } from './RulesDrawer';
|
||||
import { ModifierTooltip } from './ModifierTooltip.js';
|
||||
import { ModifierPinnedPanel } from './ModifierPinnedPanel.js';
|
||||
import { ModifierProposalDialog } from './ModifierProposalDialog.js';
|
||||
import { useChessEngine } from '../hooks/useChessEngine';
|
||||
import { useMultiplayerGame } from '../hooks/useMultiplayerGame';
|
||||
import type { ChessFact, ChessAttrMap, PieceType } from '../schema';
|
||||
import type { Color, PresetActivation } from '../net/types';
|
||||
import type { Color, PresetActivation, ModifierProfileWire } from '../net/types';
|
||||
import type { GameResult } from '../engine';
|
||||
import type { LegalMove } from '../rules/types';
|
||||
import type { ChessEngine } from '../engine';
|
||||
|
|
@ -38,6 +39,10 @@ interface GameEngineState {
|
|||
refresh: () => void;
|
||||
activations: PresetActivation[];
|
||||
setPresets: (activations: PresetActivation[]) => void;
|
||||
modifierProposal?: { profile: ModifierProfileWire; expiresAt: number; proposer: Color } | null;
|
||||
modifierRejectionMessage?: string | null;
|
||||
isProposer?: boolean;
|
||||
sendConsent?: (decision: 'approve' | 'reject') => void;
|
||||
}
|
||||
|
||||
interface GameViewProps {
|
||||
|
|
@ -140,7 +145,16 @@ function GameLayout({
|
|||
refresh,
|
||||
activations,
|
||||
setPresets,
|
||||
} = state;
|
||||
modifierProposal,
|
||||
modifierRejectionMessage,
|
||||
isProposer,
|
||||
sendConsent,
|
||||
} = state as GameEngineState & {
|
||||
modifierProposal?: { profile: ModifierProfileWire; expiresAt: number; proposer: Color } | null;
|
||||
modifierRejectionMessage?: string | null;
|
||||
isProposer?: boolean;
|
||||
sendConsent?: (decision: 'approve' | 'reject') => void;
|
||||
};
|
||||
|
||||
const handleMove = (from: number, to: number, promoteTo?: PieceType) => {
|
||||
applyMove(from, to, promoteTo || 'queen');
|
||||
|
|
@ -252,6 +266,13 @@ function GameLayout({
|
|||
setPresets={setPresets}
|
||||
onRulesChanged={refresh}
|
||||
/>
|
||||
<ModifierProposalDialog
|
||||
proposal={modifierProposal || null}
|
||||
rejectionMessage={modifierRejectionMessage || null}
|
||||
isProposer={isProposer ?? false}
|
||||
onApprove={() => sendConsent?.('approve')}
|
||||
onReject={() => sendConsent?.('reject')}
|
||||
/>
|
||||
<div className="flex flex-col md:flex-row w-full items-start justify-between gap-4 px-4">
|
||||
|
||||
{/* Header/Info section */}
|
||||
|
|
|
|||
143
packages/chess/src/ui/ModifierProposalDialog.tsx
Normal file
143
packages/chess/src/ui/ModifierProposalDialog.tsx
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import type { ModifierProfileWire, InstanceModifierWire, TypeModifierWire } from '../net/types.js';
|
||||
import { MODIFIER_REGISTRY } from '../modifiers/registry.js';
|
||||
import type { ModifierKindId } from '../modifiers/types.js';
|
||||
|
||||
export interface ModifierProposalDialogProps {
|
||||
proposal: { profile: ModifierProfileWire; expiresAt: number; proposer: string } | null;
|
||||
onApprove: () => void;
|
||||
onReject: () => void;
|
||||
rejectionMessage?: string | null;
|
||||
isProposer?: boolean;
|
||||
}
|
||||
|
||||
export function ModifierProposalDialog({
|
||||
proposal,
|
||||
onApprove,
|
||||
onReject,
|
||||
rejectionMessage,
|
||||
isProposer = false,
|
||||
}: ModifierProposalDialogProps) {
|
||||
const [now, setNow] = useState(Date.now());
|
||||
|
||||
useEffect(() => {
|
||||
if (!proposal && !rejectionMessage && !isProposer) return;
|
||||
|
||||
// Update countdown timer every 100ms for smooth ticking
|
||||
const interval = setInterval(() => setNow(Date.now()), 100);
|
||||
return () => clearInterval(interval);
|
||||
}, [proposal, rejectionMessage, isProposer]);
|
||||
|
||||
if (rejectionMessage) {
|
||||
return (
|
||||
<div className="fixed top-4 left-1/2 -translate-x-1/2 z-[100]">
|
||||
<div
|
||||
data-testid="proposal-rejected-toast"
|
||||
className="px-4 py-2 bg-red-50 border border-red-200 text-red-700 text-sm font-medium rounded-md shadow-sm flex items-center gap-2"
|
||||
>
|
||||
<span>✕</span>
|
||||
{rejectionMessage}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!proposal) return null;
|
||||
|
||||
const remainingSeconds = Math.max(0, Math.ceil((proposal.expiresAt - now) / 1000));
|
||||
|
||||
if (isProposer) {
|
||||
return (
|
||||
<div className="fixed top-4 left-1/2 -translate-x-1/2 z-[100]">
|
||||
<div
|
||||
data-testid="proposal-pending-toast"
|
||||
className="px-4 py-2 bg-blue-50 border border-blue-200 text-blue-700 text-sm font-medium rounded-md shadow-sm flex items-center gap-2"
|
||||
>
|
||||
<div className="w-3 h-3 rounded-full border-2 border-blue-500 border-t-transparent animate-spin" />
|
||||
<span>Proposal sent — waiting for opponent. Expires in {remainingSeconds}s.</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Determine top 5 modifiers to show as summary
|
||||
const allModifiers: (TypeModifierWire | InstanceModifierWire)[] = [
|
||||
...(proposal.profile.perType || []),
|
||||
...(proposal.profile.perInstance || [])
|
||||
];
|
||||
|
||||
const previewModifiers = allModifiers.slice(0, 5).map((mod, i) => {
|
||||
const descriptor = MODIFIER_REGISTRY.get(mod.kind as ModifierKindId);
|
||||
if (!descriptor) return null;
|
||||
|
||||
const desc = (descriptor.describe as (v: unknown) => string)(mod.value);
|
||||
const target = 'square' in mod ? `Square ${mod.square}` : `${(mod as TypeModifierWire).color} ${(mod as TypeModifierWire).pieceType}`;
|
||||
|
||||
return (
|
||||
<li key={i} className="text-sm text-neutral-600 flex justify-between gap-4 border-b border-neutral-100 py-1 last:border-0">
|
||||
<span className="font-medium">{target}</span>
|
||||
<span className="text-right text-neutral-800">{desc}</span>
|
||||
</li>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="proposal-dialog"
|
||||
className="fixed inset-0 z-[100] bg-black/60 backdrop-blur-sm flex items-center justify-center p-4"
|
||||
>
|
||||
<div className="bg-white rounded-xl shadow-2xl w-full max-w-md overflow-hidden flex flex-col animate-in fade-in zoom-in-95 duration-200">
|
||||
<div className="px-6 py-4 border-b border-neutral-200 bg-neutral-50">
|
||||
<h2 className="text-lg font-bold text-neutral-900 flex items-center gap-2">
|
||||
<span className="capitalize">{proposal.proposer}</span> proposes a rule change
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4 flex flex-col gap-4">
|
||||
<div>
|
||||
<h3 className="font-semibold text-neutral-800 text-lg mb-1">{proposal.profile.name || "Untitled Profile"}</h3>
|
||||
<p className="text-sm text-neutral-500">
|
||||
{proposal.profile.perType?.length || 0} piece type rules, {proposal.profile.perInstance?.length || 0} specific square rules
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{allModifiers.length > 0 && (
|
||||
<div className="bg-neutral-50 rounded-lg p-3 border border-neutral-200">
|
||||
<h4 className="text-xs font-bold text-neutral-500 uppercase tracking-wider mb-2">Changes included</h4>
|
||||
<ul className="flex flex-col">
|
||||
{previewModifiers}
|
||||
{allModifiers.length > 5 && (
|
||||
<li className="text-xs text-neutral-500 italic mt-2 text-center">
|
||||
+ {allModifiers.length - 5} more
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4 border-t border-neutral-200 bg-neutral-50 flex items-center justify-between">
|
||||
<div className="text-sm font-medium text-neutral-500">
|
||||
Expires in <span className={remainingSeconds <= 10 ? "text-red-500 font-bold" : "text-neutral-700"}>{remainingSeconds}s</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
data-testid="proposal-reject"
|
||||
onClick={onReject}
|
||||
className="px-4 py-2 text-sm font-semibold text-neutral-700 bg-white border border-neutral-300 rounded hover:bg-neutral-50 transition-colors"
|
||||
>
|
||||
Reject
|
||||
</button>
|
||||
<button
|
||||
data-testid="proposal-approve"
|
||||
onClick={onApprove}
|
||||
className="px-4 py-2 text-sm font-semibold text-white bg-blue-600 rounded hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Approve
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue