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 type { GameResult } from '../engine';
|
||||||
import { GameClient } from '../net/client';
|
import { GameClient } from '../net/client';
|
||||||
import { PredictionManager } from '../net/prediction';
|
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 * as audio from '../audio';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
const WS_URL =
|
const WS_URL =
|
||||||
(import.meta as { env?: Record<string, string> }).env?.['VITE_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
|
/** Room error surfaced to the UI (e.g. ROOM_NOT_FOUND on reconnect
|
||||||
* after grace expiry). Null when healthy. */
|
* after grace expiry). Null when healthy. */
|
||||||
error: string | null;
|
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,
|
myColor: null,
|
||||||
loading: true,
|
loading: true,
|
||||||
error: null,
|
error: null,
|
||||||
|
modifierProposal: null,
|
||||||
|
modifierRejectionMessage: null,
|
||||||
|
isProposer: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mount the connection exactly once per code/token pair.
|
// 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`.
|
// The first game.state snapshot after (re)connect clears `loading`.
|
||||||
setMeta((m) => ({ ...m, loading: false }));
|
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: {
|
const onGameDelta = (e: {
|
||||||
payload: {
|
payload: {
|
||||||
gameOver: { winner: string; reason: string } | null;
|
gameOver: { winner: string; reason: string } | null;
|
||||||
|
|
@ -148,6 +182,10 @@ export function useMultiplayerGame(code: string, token: string) {
|
||||||
client.on('room.joined', onJoined);
|
client.on('room.joined', onJoined);
|
||||||
client.on('game.state', onGameState);
|
client.on('game.state', onGameState);
|
||||||
client.on('game.delta', onGameDelta);
|
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.on('error', onError);
|
||||||
|
|
||||||
client.connect(code, token).catch((err: unknown) => {
|
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('room.joined', onJoined);
|
||||||
client.off('game.state', onGameState);
|
client.off('game.state', onGameState);
|
||||||
client.off('game.delta', onGameDelta);
|
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.off('error', onError);
|
||||||
client.close();
|
client.close();
|
||||||
clientRef.current = null;
|
clientRef.current = null;
|
||||||
|
|
@ -277,5 +319,14 @@ export function useMultiplayerGame(code: string, token: string) {
|
||||||
connected: meta.connected,
|
connected: meta.connected,
|
||||||
loading: meta.loading,
|
loading: meta.loading,
|
||||||
error: meta.error,
|
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,
|
GameMovePayload,
|
||||||
GamePresetsPayload,
|
GamePresetsPayload,
|
||||||
GameStatePayload,
|
GameStatePayload,
|
||||||
|
ModifierProfileProposalPendingPayload,
|
||||||
|
ModifierProfileRejectedPayload,
|
||||||
|
ModifierProfileConsentReceivedPayload,
|
||||||
|
ModifierProfileQueuedPayload,
|
||||||
PresetActivation,
|
PresetActivation,
|
||||||
PromotionPiece,
|
PromotionPiece,
|
||||||
RoomCreatedPayload,
|
RoomCreatedPayload,
|
||||||
|
|
@ -36,6 +40,10 @@ export type GameClientEvent =
|
||||||
| { type: "game.presets"; payload: GamePresetsPayload }
|
| { type: "game.presets"; payload: GamePresetsPayload }
|
||||||
| { type: "room.created"; payload: RoomCreatedPayload }
|
| { type: "room.created"; payload: RoomCreatedPayload }
|
||||||
| { type: "room.joined"; payload: RoomJoinedPayload }
|
| { 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: "error"; payload: ErrorPayload }
|
||||||
| { type: "connected" }
|
| { type: "connected" }
|
||||||
| { type: "disconnected"; willReconnect: boolean };
|
| { type: "disconnected"; willReconnect: boolean };
|
||||||
|
|
@ -438,6 +446,18 @@ export class GameClient {
|
||||||
case "room.joined":
|
case "room.joined":
|
||||||
this.emit({ type, payload: payload as RoomJoinedPayload });
|
this.emit({ type, payload: payload as RoomJoinedPayload });
|
||||||
return;
|
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":
|
case "error":
|
||||||
this.emit({ type, payload: payload as ErrorPayload });
|
this.emit({ type, payload: payload as ErrorPayload });
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,11 @@ import { Board } from './Board';
|
||||||
import { RulesDrawer } from './RulesDrawer';
|
import { RulesDrawer } from './RulesDrawer';
|
||||||
import { ModifierTooltip } from './ModifierTooltip.js';
|
import { ModifierTooltip } from './ModifierTooltip.js';
|
||||||
import { ModifierPinnedPanel } from './ModifierPinnedPanel.js';
|
import { ModifierPinnedPanel } from './ModifierPinnedPanel.js';
|
||||||
|
import { ModifierProposalDialog } from './ModifierProposalDialog.js';
|
||||||
import { useChessEngine } from '../hooks/useChessEngine';
|
import { useChessEngine } from '../hooks/useChessEngine';
|
||||||
import { useMultiplayerGame } from '../hooks/useMultiplayerGame';
|
import { useMultiplayerGame } from '../hooks/useMultiplayerGame';
|
||||||
import type { ChessFact, ChessAttrMap, PieceType } from '../schema';
|
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 { GameResult } from '../engine';
|
||||||
import type { LegalMove } from '../rules/types';
|
import type { LegalMove } from '../rules/types';
|
||||||
import type { ChessEngine } from '../engine';
|
import type { ChessEngine } from '../engine';
|
||||||
|
|
@ -38,6 +39,10 @@ interface GameEngineState {
|
||||||
refresh: () => void;
|
refresh: () => void;
|
||||||
activations: PresetActivation[];
|
activations: PresetActivation[];
|
||||||
setPresets: (activations: PresetActivation[]) => void;
|
setPresets: (activations: PresetActivation[]) => void;
|
||||||
|
modifierProposal?: { profile: ModifierProfileWire; expiresAt: number; proposer: Color } | null;
|
||||||
|
modifierRejectionMessage?: string | null;
|
||||||
|
isProposer?: boolean;
|
||||||
|
sendConsent?: (decision: 'approve' | 'reject') => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface GameViewProps {
|
interface GameViewProps {
|
||||||
|
|
@ -140,7 +145,16 @@ function GameLayout({
|
||||||
refresh,
|
refresh,
|
||||||
activations,
|
activations,
|
||||||
setPresets,
|
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) => {
|
const handleMove = (from: number, to: number, promoteTo?: PieceType) => {
|
||||||
applyMove(from, to, promoteTo || 'queen');
|
applyMove(from, to, promoteTo || 'queen');
|
||||||
|
|
@ -252,6 +266,13 @@ function GameLayout({
|
||||||
setPresets={setPresets}
|
setPresets={setPresets}
|
||||||
onRulesChanged={refresh}
|
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">
|
<div className="flex flex-col md:flex-row w-full items-start justify-between gap-4 px-4">
|
||||||
|
|
||||||
{/* Header/Info section */}
|
{/* 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