Files
i-want-to-heal/src/pvpLiveLifecycle.ts
T
2026-06-29 22:35:49 -04:00

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)
}
}