Matou-Garou / src /hooks /useHistoricalTime.ts
Jofthomas's picture
Jofthomas HF staff
bulk
ce8b18b
raw
history blame
4.78 kB
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;