import { useCallback, useEffect, useRef, useState } from 'react' import { publishPvpMatchState, requestPvpRematch, type PvpMatchSnapshot, type PvpMatchSide, type PvpMatchStatus, type PvpRematchResponse, } from './pvpRoguelike' export type LiveMatchProgressPayload = { state: TSideState status: PvpMatchStatus stage: number encounterIndex: number encountersCleared: number enemyHealth: number alive: boolean elapsedTicks: number } type StartLiveMatchSyncOptions = { matchId: string intervalMs?: number getPayload: () => LiveMatchProgressPayload onSnapshot: (snapshot: PvpMatchSnapshot) => void onError?: (reason: unknown) => void } type RequestLiveRematchOptions = { matchId: string maxPendingAttempts?: number maxFailureAttempts?: number pendingDelayMs?: number failureDelayMs?: number onMatched: (result: Required, 'match' | 'side'>>) => void onExpired: () => void onFailure: (reason: unknown) => void } type LivePvpMatchBase = { id: string opponentName: string } type UsePvpLiveMatchSyncOptions = { liveMatch: TLiveMatch | null syncEnabled: boolean getPayload: (liveMatch: TLiveMatch) => LiveMatchProgressPayload onSnapshot: (snapshot: PvpMatchSnapshot, liveMatch: TLiveMatch) => void onError?: (reason: unknown) => void startLiveMatch: (match: PvpMatchSnapshot, side: PvpMatchSide, message?: string) => void } type RematchState = { matchId: string | null requested: boolean message: string } /** * React wrapper for live PvP state sync and rematch UX. Mode screens provide * payload and snapshot rules; shared polling and rematch state live here. */ export function usePvpLiveMatchSync({ liveMatch, syncEnabled, getPayload, onSnapshot, onError, startLiveMatch, }: UsePvpLiveMatchSyncOptions) { const [rematchState, setRematchState] = useState({ matchId: null, requested: false, message: '', }) const getPayloadRef = useRef(getPayload) const onSnapshotRef = useRef(onSnapshot) const onErrorRef = useRef(onError) const startLiveMatchRef = useRef(startLiveMatch) const liveMatchId = liveMatch?.id useEffect(() => { getPayloadRef.current = getPayload onSnapshotRef.current = onSnapshot onErrorRef.current = onError startLiveMatchRef.current = startLiveMatch }, [getPayload, onError, onSnapshot, startLiveMatch]) useEffect(() => { if (!liveMatch || !syncEnabled) return undefined return startLiveMatchSync({ matchId: liveMatch.id, getPayload: () => getPayloadRef.current(liveMatch), onSnapshot: (snapshot) => onSnapshotRef.current(snapshot, liveMatch), onError: (reason) => onErrorRef.current?.(reason), }) }, [liveMatch, syncEnabled]) const handleRematch = useCallback(() => { if (!liveMatch || (rematchState.matchId === liveMatch.id && rematchState.requested)) return undefined const { id, opponentName } = liveMatch setRematchState({ matchId: id, requested: true, message: `Waiting for ${opponentName} to rematch...`, }) return requestLiveRematch({ matchId: id, onMatched: (result) => { startLiveMatchRef.current(result.match, result.side, `Rematch against ${opponentName} begins.`) }, onExpired: () => { setRematchState({ matchId: id, requested: false, message: 'Rematch expired.' }) }, onFailure: (reason) => { setRematchState({ matchId: id, requested: false, message: reason instanceof Error ? reason.message : 'Unable to request rematch.', }) }, }) }, [liveMatch, rematchState.matchId, rematchState.requested]) const currentRematchState = rematchState.matchId === liveMatchId ? rematchState : { matchId: liveMatchId ?? null, requested: false, message: '' } return { rematchRequested: currentRematchState.requested, rematchMessage: currentRematchState.message, handleRematch, } } /** * Polls the backend rematch endpoint until both players accept, the request * expires, or repeated network failures make the rematch unavailable. */ export function requestLiveRematch({ matchId, maxPendingAttempts = 180, maxFailureAttempts = 10, pendingDelayMs = 700, failureDelayMs = 900, onMatched, onExpired, onFailure, }: RequestLiveRematchOptions) { let cancelled = false let attempts = 0 const schedule = (callback: () => void, delay: number) => { window.setTimeout(callback, delay) } const handleResponse = (result: PvpRematchResponse) => { if (cancelled) return if (result.status === 'matched' && result.match && result.side) { onMatched({ match: result.match, side: result.side }) return } attempts += 1 if (attempts >= maxPendingAttempts) { onExpired() return } schedule(pollRematch, pendingDelayMs) } const pollRematch = () => { requestPvpRematch(matchId) .then(handleResponse) .catch((reason: unknown) => { if (cancelled) return attempts += 1 if (attempts >= maxFailureAttempts) { onFailure(reason) return } schedule(pollRematch, failureDelayMs) }) } pollRematch() return () => { cancelled = true } } /** * Publishes local live-match progress and polls opponent state on a fixed * interval. Mode-specific snapshot handling stays in the caller. */ export function startLiveMatchSync({ matchId, intervalMs = 700, getPayload, onSnapshot, onError = () => undefined, }: StartLiveMatchSyncOptions) { let stopped = false const syncMatch = () => { publishPvpMatchState(matchId, getPayload()) .then((snapshot) => { if (!stopped) onSnapshot(snapshot) }) .catch((reason: unknown) => { if (!stopped) onError(reason) }) } syncMatch() const timer = window.setInterval(syncMatch, intervalMs) return () => { stopped = true window.clearInterval(timer) } }