diff --git a/packages/chess/src/hooks/useMultiplayerGame.ts b/packages/chess/src/hooks/useMultiplayerGame.ts index 696661a..23ef3cd 100644 --- a/packages/chess/src/hooks/useMultiplayerGame.ts +++ b/packages/chess/src/hooks/useMultiplayerGame.ts @@ -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 }).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 } + }); + }, }; } diff --git a/packages/chess/src/net/client.ts b/packages/chess/src/net/client.ts index 45295ce..59707bc 100644 --- a/packages/chess/src/net/client.ts +++ b/packages/chess/src/net/client.ts @@ -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; diff --git a/packages/chess/src/ui/GameView.tsx b/packages/chess/src/ui/GameView.tsx index be92b2a..c56e0de 100644 --- a/packages/chess/src/ui/GameView.tsx +++ b/packages/chess/src/ui/GameView.tsx @@ -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} /> + sendConsent?.('approve')} + onReject={() => sendConsent?.('reject')} + />
{/* Header/Info section */} diff --git a/packages/chess/src/ui/ModifierProposalDialog.tsx b/packages/chess/src/ui/ModifierProposalDialog.tsx new file mode 100644 index 0000000..7ef6591 --- /dev/null +++ b/packages/chess/src/ui/ModifierProposalDialog.tsx @@ -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 ( +
+
+ ✕ + {rejectionMessage} +
+
+ ); + } + + if (!proposal) return null; + + const remainingSeconds = Math.max(0, Math.ceil((proposal.expiresAt - now) / 1000)); + + if (isProposer) { + return ( +
+
+
+ Proposal sent — waiting for opponent. Expires in {remainingSeconds}s. +
+
+ ); + } + + // 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 ( +
  • + {target} + {desc} +
  • + ); + }); + + return ( +
    +
    +
    +

    + {proposal.proposer} proposes a rule change +

    +
    + +
    +
    +

    {proposal.profile.name || "Untitled Profile"}

    +

    + {proposal.profile.perType?.length || 0} piece type rules, {proposal.profile.perInstance?.length || 0} specific square rules +

    +
    + + {allModifiers.length > 0 && ( +
    +

    Changes included

    +
      + {previewModifiers} + {allModifiers.length > 5 && ( +
    • + + {allModifiers.length - 5} more +
    • + )} +
    +
    + )} +
    + +
    +
    + Expires in {remainingSeconds}s +
    +
    + + +
    +
    +
    +
    + ); +}