feat: 메인 현재 시각을 최근 통신 기준으로 갱신

완료 턴 시각 대신 lobby serverTime을 분 경계에 맞춰 표시한다. 게임 API 응답과 SSE 및 같은 계정 탭 전달을 최근 통신으로 기록하고, 45초 공백에는 시계를 멈춘다. 공통 시계 투영과 단위 및 Chromium 회귀 검증을 추가한다.
This commit is contained in:
2026-08-21 17:19:46 +00:00
parent b82492e6d1
commit 1e14320911
10 changed files with 374 additions and 35 deletions
@@ -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;
};
+6
View File
@@ -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);