222 lines
6.2 KiB
TypeScript
222 lines
6.2 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
import {
|
|
publishPvpMatchState,
|
|
requestPvpRematch,
|
|
type PvpMatchSnapshot,
|
|
type PvpMatchSide,
|
|
type PvpMatchStatus,
|
|
type PvpRematchResponse,
|
|
} from './pvpRoguelike'
|
|
|
|
export type LiveMatchProgressPayload<TSideState> = {
|
|
state: TSideState
|
|
status: PvpMatchStatus
|
|
stage: number
|
|
encounterIndex: number
|
|
encountersCleared: number
|
|
enemyHealth: number
|
|
alive: boolean
|
|
elapsedTicks: number
|
|
}
|
|
|
|
type StartLiveMatchSyncOptions<TSideState> = {
|
|
matchId: string
|
|
intervalMs?: number
|
|
getPayload: () => LiveMatchProgressPayload<TSideState>
|
|
onSnapshot: (snapshot: PvpMatchSnapshot<TSideState>) => void
|
|
onError?: (reason: unknown) => void
|
|
}
|
|
|
|
type RequestLiveRematchOptions<TSideState> = {
|
|
matchId: string
|
|
maxPendingAttempts?: number
|
|
maxFailureAttempts?: number
|
|
pendingDelayMs?: number
|
|
failureDelayMs?: number
|
|
onMatched: (result: Required<Pick<PvpRematchResponse<TSideState>, 'match' | 'side'>>) => void
|
|
onExpired: () => void
|
|
onFailure: (reason: unknown) => void
|
|
}
|
|
|
|
type LivePvpMatchBase = {
|
|
id: string
|
|
opponentName: string
|
|
}
|
|
|
|
type UsePvpLiveMatchSyncOptions<TSideState, TLiveMatch extends LivePvpMatchBase> = {
|
|
liveMatch: TLiveMatch | null
|
|
syncEnabled: boolean
|
|
getPayload: (liveMatch: TLiveMatch) => LiveMatchProgressPayload<TSideState>
|
|
onSnapshot: (snapshot: PvpMatchSnapshot<TSideState>, liveMatch: TLiveMatch) => void
|
|
onError?: (reason: unknown) => void
|
|
startLiveMatch: (match: PvpMatchSnapshot<TSideState>, 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<TSideState, TLiveMatch extends LivePvpMatchBase>({
|
|
liveMatch,
|
|
syncEnabled,
|
|
getPayload,
|
|
onSnapshot,
|
|
onError,
|
|
startLiveMatch,
|
|
}: UsePvpLiveMatchSyncOptions<TSideState, TLiveMatch>) {
|
|
const [rematchState, setRematchState] = useState<RematchState>({
|
|
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<TSideState>({
|
|
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<TSideState>({
|
|
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<TSideState>({
|
|
matchId,
|
|
maxPendingAttempts = 180,
|
|
maxFailureAttempts = 10,
|
|
pendingDelayMs = 700,
|
|
failureDelayMs = 900,
|
|
onMatched,
|
|
onExpired,
|
|
onFailure,
|
|
}: RequestLiveRematchOptions<TSideState>) {
|
|
let cancelled = false
|
|
let attempts = 0
|
|
|
|
const schedule = (callback: () => void, delay: number) => {
|
|
window.setTimeout(callback, delay)
|
|
}
|
|
|
|
const handleResponse = (result: PvpRematchResponse<TSideState>) => {
|
|
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<TSideState>(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<TSideState>({
|
|
matchId,
|
|
intervalMs = 700,
|
|
getPayload,
|
|
onSnapshot,
|
|
onError = () => undefined,
|
|
}: StartLiveMatchSyncOptions<TSideState>) {
|
|
let stopped = false
|
|
const syncMatch = () => {
|
|
publishPvpMatchState<TSideState>(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)
|
|
}
|
|
}
|