Newer
Older
PixelPaintWar / apps / client / src / scenes / game / application / time / ClockSyncService.ts
/**
 * ClockSyncService
 * サーバー時刻との差分を平滑化して管理する
 * PING/PONGのRTTを使い,外れ値を除外して同期精度を安定化する
 */
import type { PongPayload } from "@repo/shared";

/** 時刻同期に利用する更新パラメータ */
export type ClockSyncConfig = {
  offsetAlpha: number;
  rttAlpha: number;
  maxAcceptedRttMs: number;
  maxAcceptedOffsetJumpMs: number;
};

/** 時刻同期の既定パラメータ */
export const DEFAULT_CLOCK_SYNC_CONFIG: ClockSyncConfig = {
  offsetAlpha: 0.12,
  rttAlpha: 0.25,
  maxAcceptedRttMs: 1000,
  maxAcceptedOffsetJumpMs: 250,
};

/** サーバー時刻との差分を平滑化して保持する */
export class ClockSyncService {
  private readonly config: ClockSyncConfig;
  private smoothedOffsetMs: number | null = null;
  private smoothedRttMs: number | null = null;

  constructor(config: Partial<ClockSyncConfig> = {}) {
    this.config = {
      ...DEFAULT_CLOCK_SYNC_CONFIG,
      ...config,
    };
  }

  /** 受信した serverNow をもとに差分を初期化する */
  public seedFromServerNow(serverNowMs: number, receivedAtMs = Date.now()): void {
    this.smoothedOffsetMs = serverNowMs - receivedAtMs;
  }

  /** PONGサンプルを取り込み,差分とRTTを更新する */
  public updateFromPong(payload: PongPayload, receivedAtMs = Date.now()): void {
    const measuredRttMs = receivedAtMs - payload.clientTime;
    if (measuredRttMs < 0 || measuredRttMs > this.config.maxAcceptedRttMs) {
      return;
    }

    const estimatedOneWayMs = measuredRttMs / 2;
    const measuredOffsetMs =
      payload.serverTime - (payload.clientTime + estimatedOneWayMs);

    this.smoothedRttMs = this.smoothValue(
      this.smoothedRttMs,
      measuredRttMs,
      this.config.rttAlpha,
    );

    if (
      this.smoothedOffsetMs !== null &&
      Math.abs(measuredOffsetMs - this.smoothedOffsetMs) >
        this.config.maxAcceptedOffsetJumpMs
    ) {
      return;
    }

    this.smoothedOffsetMs = this.smoothValue(
      this.smoothedOffsetMs,
      measuredOffsetMs,
      this.config.offsetAlpha,
    );
  }

  /** 平滑化済みの時刻差分ミリ秒を返す */
  public getClockOffsetMs(): number {
    return this.smoothedOffsetMs ?? 0;
  }

  /** サーバー時刻基準へ補正した現在時刻ミリ秒を返す */
  public getSynchronizedNowMs(): number {
    return Date.now() + this.getClockOffsetMs();
  }

  /** RTT状況に応じた次回同期推奨間隔ミリ秒を返す */
  public getRecommendedSyncIntervalMs(): number {
    if (this.smoothedRttMs === null) {
      return 3000;
    }

    if (this.smoothedRttMs <= 80) {
      return 5000;
    }

    if (this.smoothedRttMs <= 180) {
      return 3000;
    }

    return 2000;
  }

  /** 内部状態を初期化する */
  public reset(): void {
    this.smoothedOffsetMs = null;
    this.smoothedRttMs = null;
  }

  private smoothValue(
    current: number | null,
    measured: number,
    alpha: number,
  ): number {
    if (current === null) {
      return measured;
    }

    return current * (1 - alpha) + measured * alpha;
  }
}