feat: 메인 현재 시각을 최근 통신 기준으로 갱신
완료 턴 시각 대신 lobby serverTime을 분 경계에 맞춰 표시한다. 게임 API 응답과 SSE 및 같은 계정 탭 전달을 최근 통신으로 기록하고, 45초 공백에는 시계를 멈춘다. 공통 시계 투영과 단위 및 Chromium 회귀 검증을 추가한다.
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
import { readonly, ref, type Ref } from 'vue';
|
||||
|
||||
export const GAME_SERVER_ACTIVITY_FRESHNESS_MS = 45_000;
|
||||
|
||||
export type GameServerActivityTracker = {
|
||||
lastContactAt: Readonly<Ref<number | null>>;
|
||||
markContact: (contactAt?: number) => void;
|
||||
};
|
||||
|
||||
export const createGameServerActivityTracker = (): GameServerActivityTracker => {
|
||||
const lastContactAt = ref<number | null>(null);
|
||||
|
||||
return {
|
||||
lastContactAt: readonly(lastContactAt),
|
||||
markContact(contactAt = Date.now()) {
|
||||
if (!Number.isFinite(contactAt)) return;
|
||||
lastContactAt.value = contactAt;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const isRecentGameServerActivity = (
|
||||
lastContactAt: number | null,
|
||||
now = Date.now(),
|
||||
freshnessMs = GAME_SERVER_ACTIVITY_FRESHNESS_MS
|
||||
): boolean =>
|
||||
lastContactAt !== null &&
|
||||
Number.isFinite(lastContactAt) &&
|
||||
Number.isFinite(now) &&
|
||||
Math.max(0, now - lastContactAt) <= freshnessMs;
|
||||
|
||||
export const gameServerActivity = createGameServerActivityTracker();
|
||||
|
||||
export const markGameServerContact = (contactAt = Date.now()) => gameServerActivity.markContact(contactAt);
|
||||
@@ -0,0 +1,67 @@
|
||||
export type ServerClockProjectionInput = {
|
||||
serverTime?: string;
|
||||
serverWallTime?: string;
|
||||
clockMode?: 'realtime' | 'manual';
|
||||
clockRunning?: boolean;
|
||||
clockStartsAt?: string | null;
|
||||
};
|
||||
|
||||
export type SampledServerClock = {
|
||||
serverTimeMs: number;
|
||||
sampledClientTimeMs: number;
|
||||
clockMode: 'realtime' | 'manual';
|
||||
startDelayMs: number | null;
|
||||
};
|
||||
|
||||
const parseInstant = (value?: string | null): number | null => {
|
||||
if (!value) return null;
|
||||
const parsed = new Date(value).getTime();
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
};
|
||||
|
||||
export const sampleServerClock = (
|
||||
input: ServerClockProjectionInput,
|
||||
sampledClientTimeMs = Date.now()
|
||||
): SampledServerClock | null => {
|
||||
const serverTimeMs = parseInstant(input.serverTime);
|
||||
if (serverTimeMs === null) return null;
|
||||
|
||||
let startDelayMs: number | null;
|
||||
if (input.clockMode === 'manual') {
|
||||
startDelayMs = null;
|
||||
} else if (input.clockRunning !== false) {
|
||||
startDelayMs = 0;
|
||||
} else {
|
||||
const serverWallTimeMs = parseInstant(input.serverWallTime);
|
||||
const clockStartsAtMs = parseInstant(input.clockStartsAt);
|
||||
startDelayMs =
|
||||
serverWallTimeMs !== null && clockStartsAtMs !== null
|
||||
? Math.max(0, clockStartsAtMs - serverWallTimeMs)
|
||||
: null;
|
||||
}
|
||||
|
||||
return {
|
||||
serverTimeMs,
|
||||
sampledClientTimeMs,
|
||||
clockMode: input.clockMode ?? 'realtime',
|
||||
startDelayMs,
|
||||
};
|
||||
};
|
||||
|
||||
export const projectServerClock = (sample: SampledServerClock, clientTimeMs = Date.now()) => {
|
||||
const clientElapsedMs = Math.max(0, clientTimeMs - sample.sampledClientTimeMs);
|
||||
const elapsedGameMs =
|
||||
sample.clockMode === 'manual' || sample.startDelayMs === null
|
||||
? 0
|
||||
: Math.max(0, clientElapsedMs - sample.startDelayMs);
|
||||
|
||||
return {
|
||||
clientElapsedMs,
|
||||
time: new Date(sample.serverTimeMs + elapsedGameMs),
|
||||
};
|
||||
};
|
||||
|
||||
export const millisecondsUntilNextMinute = (time: Date): number => {
|
||||
const remainder = ((time.getTime() % 60_000) + 60_000) % 60_000;
|
||||
return remainder === 0 ? 60_000 : 60_000 - remainder;
|
||||
};
|
||||
@@ -3,6 +3,7 @@ import { REALTIME_ACCESS_GRANT_HEADER } from '@sammo-ts/common/realtime/types';
|
||||
import { createTRPCProxyClient, httpBatchLink } from '@trpc/client';
|
||||
import type { AppRouter } from '@sammo-ts/game-api';
|
||||
import { resolveBatchRealtimeAccessGrant } from './realtimeAccessGrant';
|
||||
import { markGameServerContact } from './gameServerActivity';
|
||||
|
||||
const getGameToken = (): string | null => {
|
||||
if (typeof window === 'undefined') {
|
||||
@@ -17,6 +18,11 @@ export const trpc = createTRPCProxyClient<AppRouter>({
|
||||
httpBatchLink({
|
||||
url: import.meta.env.VITE_GAME_API_URL ?? '/api/trpc',
|
||||
...trpcJsonBodyHttpClientOptions,
|
||||
async fetch(input, init) {
|
||||
const result = await globalThis.fetch(input, init);
|
||||
markGameServerContact();
|
||||
return result;
|
||||
},
|
||||
headers({ opList }) {
|
||||
const token = getGameToken();
|
||||
const refreshGrant = resolveBatchRealtimeAccessGrant(opList);
|
||||
|
||||
Reference in New Issue
Block a user