Spaces:
Sleeping
Sleeping
import { Doc } from '../../convex/_generated/dataModel'; | |
import { useEffect, useRef, useState } from 'react'; | |
export function useHistoricalTime(engineStatus?: Doc<'engines'>) { | |
const timeManager = useRef(new HistoricalTimeManager()); | |
const rafRef = useRef<number>(); | |
const [historicalTime, setHistoricalTime] = useState<number | undefined>(undefined); | |
if (engineStatus) { | |
timeManager.current.receive(engineStatus); | |
} | |
const updateTime = (performanceNow: number) => { | |
// We don't need sub-millisecond precision for interpolation, so just use `Date.now()`. | |
const now = Date.now(); | |
setHistoricalTime(timeManager.current.historicalServerTime(now)); | |
rafRef.current = requestAnimationFrame(updateTime); | |
}; | |
useEffect(() => { | |
rafRef.current = requestAnimationFrame(updateTime); | |
return () => cancelAnimationFrame(rafRef.current!); | |
}, []); | |
return { historicalTime, timeManager: timeManager.current }; | |
} | |
type ServerTimeInterval = { | |
startTs: number; | |
endTs: number; | |
}; | |
export class HistoricalTimeManager { | |
intervals: Array<ServerTimeInterval> = []; | |
prevClientTs?: number; | |
prevServerTs?: number; | |
totalDuration: number = 0; | |
latestEngineStatus?: Doc<'engines'>; | |
receive(engineStatus: Doc<'engines'>) { | |
this.latestEngineStatus = engineStatus; | |
if (!engineStatus.currentTime || !engineStatus.lastStepTs) { | |
return; | |
} | |
const latest = this.intervals[this.intervals.length - 1]; | |
if (latest) { | |
if (latest.endTs === engineStatus.currentTime) { | |
return; | |
} | |
if (latest.endTs > engineStatus.currentTime) { | |
throw new Error(`Received out-of-order engine status`); | |
} | |
} | |
const newInterval = { | |
startTs: engineStatus.lastStepTs, | |
endTs: engineStatus.currentTime, | |
}; | |
this.intervals.push(newInterval); | |
this.totalDuration += newInterval.endTs - newInterval.startTs; | |
} | |
historicalServerTime(clientNow: number): number | undefined { | |
if (this.intervals.length == 0) { | |
return undefined; | |
} | |
if (clientNow === this.prevClientTs) { | |
return this.prevServerTs; | |
} | |
// If this is our first time simulating, start at the beginning of the buffer. | |
const prevClientTs = this.prevClientTs ?? clientNow; | |
const prevServerTs = this.prevServerTs ?? this.intervals[0].startTs; | |
const lastServerTs = this.intervals[this.intervals.length - 1].endTs; | |
// Simple rate adjustment: run time at 1.2 speed if we're more than 1s behind and | |
// 0.8 speed if we only have 100ms of buffer left. A more sophisticated approach | |
// would be to continuously adjust the rate based on the size of the buffer. | |
const bufferDuration = lastServerTs - prevServerTs; | |
let rate = 1; | |
if (bufferDuration < SOFT_MIN_SERVER_BUFFER_AGE) { | |
rate = 0.8; | |
} else if (bufferDuration > SOFT_MAX_SERVER_BUFFER_AGE) { | |
rate = 1.2; | |
} | |
let serverTs = Math.max( | |
prevServerTs + (clientNow - prevClientTs) * rate, | |
// Jump forward if we're too far behind. | |
lastServerTs - MAX_SERVER_BUFFER_AGE, | |
); | |
let chosen = null; | |
for (let i = 0; i < this.intervals.length; i++) { | |
const snapshot = this.intervals[i]; | |
// We're past this snapshot, continue to the next one. | |
if (snapshot.endTs < serverTs) { | |
continue; | |
} | |
// We're cleanly within this snapshot. | |
if (serverTs >= snapshot.startTs) { | |
chosen = i; | |
break; | |
} | |
// We've gone past the desired timestamp, which implies a gap in our server state. | |
// Jump time forward to the beginning of this snapshot. | |
if (serverTs < snapshot.startTs) { | |
serverTs = snapshot.startTs; | |
chosen = i; | |
} | |
} | |
if (chosen === null) { | |
serverTs = this.intervals.at(-1)!.endTs; | |
chosen = this.intervals.length - 1; | |
} | |
// Time only moves forward, so we can trim all of the snapshots before our chosen one. | |
const toTrim = Math.max(chosen - 1, 0); | |
if (toTrim > 0) { | |
for (const snapshot of this.intervals.slice(0, toTrim)) { | |
this.totalDuration -= snapshot.endTs - snapshot.startTs; | |
} | |
this.intervals = this.intervals.slice(toTrim); | |
} | |
this.prevClientTs = clientNow; | |
this.prevServerTs = serverTs; | |
return serverTs; | |
} | |
bufferHealth(): number { | |
if (!this.intervals.length) { | |
return 0; | |
} | |
const lastServerTs = this.prevServerTs ?? this.intervals[0].startTs; | |
return this.intervals[this.intervals.length - 1].endTs - lastServerTs; | |
} | |
clockSkew(): number { | |
if (!this.prevClientTs || !this.prevServerTs) { | |
return 0; | |
} | |
return this.prevClientTs - this.prevServerTs; | |
} | |
} | |
const MAX_SERVER_BUFFER_AGE = 1500; | |
const SOFT_MAX_SERVER_BUFFER_AGE = 1250; | |
const SOFT_MIN_SERVER_BUFFER_AGE = 250; | |