merge: 로컬 서버 시계와 생성 첫 턴 수정
This commit is contained in:
@@ -4,6 +4,7 @@ import { asRecord } from '@sammo-ts/common';
|
|||||||
|
|
||||||
import { zWorldStateConfig, zWorldStateMeta } from '../../context.js';
|
import { zWorldStateConfig, zWorldStateMeta } from '../../context.js';
|
||||||
import { isSelectionPoolWorld, resolveSelectionMaxGeneral } from '@sammo-ts/game-engine/turn/selectPoolService.js';
|
import { isSelectionPoolWorld, resolveSelectionMaxGeneral } from '@sammo-ts/game-engine/turn/selectPoolService.js';
|
||||||
|
import { loadCurrentGameTime } from '../../services/gameClock.js';
|
||||||
import { procedure, router } from '../../trpc.js';
|
import { procedure, router } from '../../trpc.js';
|
||||||
|
|
||||||
export const lobbyRouter = router({
|
export const lobbyRouter = router({
|
||||||
@@ -26,6 +27,7 @@ export const lobbyRouter = router({
|
|||||||
const npcCnt = await ctx.db.general.count({ where: { npcState: { gte: 2 } } });
|
const npcCnt = await ctx.db.general.count({ where: { npcState: { gte: 2 } } });
|
||||||
const nationCnt = await ctx.db.nation.count({ where: { level: { gt: 0 } } });
|
const nationCnt = await ctx.db.nation.count({ where: { level: { gt: 0 } } });
|
||||||
const scenarioTitle = asRecord(asRecord(rawWorldState.meta).scenarioMeta).title;
|
const scenarioTitle = asRecord(asRecord(rawWorldState.meta).scenarioMeta).title;
|
||||||
|
const gameTime = await loadCurrentGameTime(ctx.db);
|
||||||
|
|
||||||
let myGeneral = null;
|
let myGeneral = null;
|
||||||
if (ctx.auth?.user.id) {
|
if (ctx.auth?.user.id) {
|
||||||
@@ -54,6 +56,8 @@ export const lobbyRouter = router({
|
|||||||
starttime: worldState.meta.starttime ?? '',
|
starttime: worldState.meta.starttime ?? '',
|
||||||
opentime: worldState.meta.opentime ?? '',
|
opentime: worldState.meta.opentime ?? '',
|
||||||
turntime: worldState.meta.turntime ?? '',
|
turntime: worldState.meta.turntime ?? '',
|
||||||
|
serverTime: gameTime.now.toISOString(),
|
||||||
|
clockMode: gameTime.mode ?? 'realtime',
|
||||||
otherTextInfo: worldState.meta.otherTextInfo ?? '',
|
otherTextInfo: worldState.meta.otherTextInfo ?? '',
|
||||||
isUnited: worldState.meta.isunited ?? worldState.meta.isUnited ?? 0,
|
isUnited: worldState.meta.isunited ?? worldState.meta.isUnited ?? 0,
|
||||||
selectionPoolEnabled: isSelectionPoolWorld(rawWorldState),
|
selectionPoolEnabled: isSelectionPoolWorld(rawWorldState),
|
||||||
|
|||||||
@@ -3,7 +3,15 @@ import { describe, expect, it, vi } from 'vitest';
|
|||||||
import type { DatabaseClient, GameApiContext } from '../src/context.js';
|
import type { DatabaseClient, GameApiContext } from '../src/context.js';
|
||||||
import { appRouter } from '../src/router.js';
|
import { appRouter } from '../src/router.js';
|
||||||
|
|
||||||
const buildContext = (meta: Record<string, unknown>): GameApiContext =>
|
const buildContext = (
|
||||||
|
meta: Record<string, unknown>,
|
||||||
|
clock: {
|
||||||
|
baseTime?: Date;
|
||||||
|
tick?: bigint;
|
||||||
|
mode?: string;
|
||||||
|
wallAnchor?: Date;
|
||||||
|
} = {}
|
||||||
|
): GameApiContext =>
|
||||||
({
|
({
|
||||||
auth: null,
|
auth: null,
|
||||||
db: {
|
db: {
|
||||||
@@ -16,6 +24,10 @@ const buildContext = (meta: Record<string, unknown>): GameApiContext =>
|
|||||||
tickSeconds: 3_600,
|
tickSeconds: 3_600,
|
||||||
config: {},
|
config: {},
|
||||||
meta,
|
meta,
|
||||||
|
clockBaseTime: clock.baseTime ?? null,
|
||||||
|
clockTick: clock.tick ?? null,
|
||||||
|
clockMode: clock.mode ?? 'realtime',
|
||||||
|
clockWallAnchor: clock.wallAnchor ?? null,
|
||||||
updatedAt: new Date('2026-07-31T00:00:00.000Z'),
|
updatedAt: new Date('2026-07-31T00:00:00.000Z'),
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
@@ -36,4 +48,23 @@ describe('lobby season state', () => {
|
|||||||
|
|
||||||
expect(result.isUnited).toBe(isunited);
|
expect(result.isUnited).toBe(isunited);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('returns the projected server game time and whether the clock is running', async () => {
|
||||||
|
const result = await appRouter
|
||||||
|
.createCaller(
|
||||||
|
buildContext(
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
baseTime: new Date('2026-08-15T00:00:00.000Z'),
|
||||||
|
tick: 72_000_000n,
|
||||||
|
mode: 'manual',
|
||||||
|
wallAnchor: new Date('2026-08-15T17:00:00.000Z'),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.lobby.info();
|
||||||
|
|
||||||
|
expect(result.serverTime).toBe('2026-08-15T02:00:00.000Z');
|
||||||
|
expect(result.clockMode).toBe('manual');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -330,8 +330,8 @@ export const cutJoinTurnTime = (value: Date, tickSeconds: number): Date => {
|
|||||||
return new Date(baseTime + alignedSeconds * 1000);
|
return new Date(baseTime + alignedSeconds * 1000);
|
||||||
};
|
};
|
||||||
|
|
||||||
const resolveTurnTime = (
|
export const resolveJoinTurnTime = (
|
||||||
rng: RandUtil,
|
rng: Pick<RandUtil, 'nextRangeInt'>,
|
||||||
worldState: WorldStateRow,
|
worldState: WorldStateRow,
|
||||||
acceptedAt: Date,
|
acceptedAt: Date,
|
||||||
runtimeTurnTime: Date,
|
runtimeTurnTime: Date,
|
||||||
@@ -348,7 +348,12 @@ const resolveTurnTime = (
|
|||||||
offsetSeconds = inheritTurntimeZone * legacyTurnTermMinutes + rng.nextRangeInt(0, legacyTurnTermMinutes - 1);
|
offsetSeconds = inheritTurntimeZone * legacyTurnTermMinutes + rng.nextRangeInt(0, legacyTurnTermMinutes - 1);
|
||||||
offsetMicros = rng.nextRangeInt(0, 999_999);
|
offsetMicros = rng.nextRangeInt(0, 999_999);
|
||||||
} else {
|
} else {
|
||||||
turnTimeBase = base;
|
// Ref normally uses game_env.turntime as a near-current cursor. Core's
|
||||||
|
// durable daemon can legitimately be catching up from an older cursor,
|
||||||
|
// so scheduling from runtimeTurnTime may put a newly created general
|
||||||
|
// hours behind the game clock. The accepted game time is the equivalent
|
||||||
|
// current-time boundary for a new general.
|
||||||
|
turnTimeBase = acceptedAt;
|
||||||
offsetSeconds = rng.nextRangeInt(0, tickSeconds - 1);
|
offsetSeconds = rng.nextRangeInt(0, tickSeconds - 1);
|
||||||
offsetMicros = rng.nextRangeInt(0, 999_999);
|
offsetMicros = rng.nextRangeInt(0, 999_999);
|
||||||
}
|
}
|
||||||
@@ -662,7 +667,7 @@ export const createGeneralFromJoin = async (options: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const experience = await resolveCatchupExperience(db, relativeYear);
|
const experience = await resolveCatchupExperience(db, relativeYear);
|
||||||
const turnTime = resolveTurnTime(
|
const turnTime = resolveJoinTurnTime(
|
||||||
rng,
|
rng,
|
||||||
worldState,
|
worldState,
|
||||||
acceptedAt,
|
acceptedAt,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
buildJoinCreateGeneralSeed,
|
buildJoinCreateGeneralSeed,
|
||||||
cutJoinTurnTime,
|
cutJoinTurnTime,
|
||||||
JOIN_WELCOME_MESSAGE,
|
JOIN_WELCOME_MESSAGE,
|
||||||
|
resolveJoinTurnTime,
|
||||||
} from '../src/turn/joinCreateGeneralService.js';
|
} from '../src/turn/joinCreateGeneralService.js';
|
||||||
|
|
||||||
describe('generic join legacy time contracts', () => {
|
describe('generic join legacy time contracts', () => {
|
||||||
@@ -19,6 +20,35 @@ describe('generic join legacy time contracts', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('schedules a new general within one turn of the accepted game time even when the daemon cursor is stale', () => {
|
||||||
|
const calls: Array<[number, number]> = [];
|
||||||
|
const values = [59, 250_000];
|
||||||
|
const rng = {
|
||||||
|
nextRangeInt(min: number, max: number) {
|
||||||
|
calls.push([min, max]);
|
||||||
|
return values.shift() ?? min;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const acceptedAt = new Date('2026-08-15T17:57:05.837Z');
|
||||||
|
const staleRuntimeTurnTime = new Date('2026-08-15T07:10:00.000Z');
|
||||||
|
|
||||||
|
const turnTime = resolveJoinTurnTime(
|
||||||
|
rng,
|
||||||
|
{ tickSeconds: 120 } as Parameters<typeof resolveJoinTurnTime>[1],
|
||||||
|
acceptedAt,
|
||||||
|
staleRuntimeTurnTime,
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(turnTime.toISOString()).toBe('2026-08-15T17:58:05.087Z');
|
||||||
|
expect(turnTime.getTime()).toBeGreaterThan(acceptedAt.getTime());
|
||||||
|
expect(turnTime.getTime()).toBeLessThanOrEqual(acceptedAt.getTime() + 120_000);
|
||||||
|
expect(calls).toEqual([
|
||||||
|
[0, 119],
|
||||||
|
[0, 999_999],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it('uses the HiDCHe product name without the legacy PHP runtime label', () => {
|
it('uses the HiDCHe product name without the legacy PHP runtime label', () => {
|
||||||
expect(JOIN_WELCOME_MESSAGE).toBe('삼국지 모의전투 HiDCHe의 세계에 오신 것을 환영합니다 ^o^');
|
expect(JOIN_WELCOME_MESSAGE).toBe('삼국지 모의전투 HiDCHe의 세계에 오신 것을 환영합니다 ^o^');
|
||||||
expect(JOIN_WELCOME_MESSAGE).not.toContain('PHP');
|
expect(JOIN_WELCOME_MESSAGE).not.toContain('PHP');
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ type NavigationFixture = {
|
|||||||
operations: string[];
|
operations: string[];
|
||||||
generalName?: string;
|
generalName?: string;
|
||||||
generalTurnTime?: string;
|
generalTurnTime?: string;
|
||||||
|
serverTime?: string;
|
||||||
|
clockMode?: 'realtime' | 'manual';
|
||||||
cityDefence?: number;
|
cityDefence?: number;
|
||||||
cityState?: number;
|
cityState?: number;
|
||||||
nationRate?: number;
|
nationRate?: number;
|
||||||
@@ -363,6 +365,8 @@ const installFixture = async (page: Page, state: NavigationFixture) => {
|
|||||||
year: state.currentYear ?? 185,
|
year: state.currentYear ?? 185,
|
||||||
month: state.currentMonth ?? 1,
|
month: state.currentMonth ?? 1,
|
||||||
turnTerm: 10,
|
turnTerm: 10,
|
||||||
|
serverTime: state.serverTime ?? '2026-08-13T00:00:00.000Z',
|
||||||
|
clockMode: state.clockMode ?? 'realtime',
|
||||||
scenarioTitle: state.scenarioTitle ?? '',
|
scenarioTitle: state.scenarioTitle ?? '',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -787,7 +791,7 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro
|
|||||||
await persistArtifact(page, `${basePath.slice(1)}-desktop-1200`);
|
await persistArtifact(page, `${basePath.slice(1)}-desktop-1200`);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('main general card and command clock render the next turn with second precision', async ({ page }) => {
|
test('main general card uses local turn time and command clock tracks corrected server time', async ({ page }) => {
|
||||||
const state: NavigationFixture = {
|
const state: NavigationFixture = {
|
||||||
officerLevel: 0,
|
officerLevel: 0,
|
||||||
permission: 0,
|
permission: 0,
|
||||||
@@ -797,22 +801,29 @@ test('main general card and command clock render the next turn with second preci
|
|||||||
generalMeCalls: 0,
|
generalMeCalls: 0,
|
||||||
operations: [],
|
operations: [],
|
||||||
generalName: 'Administrator',
|
generalName: 'Administrator',
|
||||||
generalTurnTime: '2026-08-13T00:07:06.713Z',
|
generalTurnTime: '2026-08-13T00:09:10.713Z',
|
||||||
|
serverTime: '2026-08-13T00:07:06.250Z',
|
||||||
|
clockMode: 'realtime',
|
||||||
currentYear: 179,
|
currentYear: 179,
|
||||||
currentMonth: 8,
|
currentMonth: 8,
|
||||||
};
|
};
|
||||||
await installFixture(page, state);
|
await installFixture(page, state);
|
||||||
|
await page.clock.install({ time: new Date('2026-08-13T00:00:00.000Z') });
|
||||||
|
const cdp = await page.context().newCDPSession(page);
|
||||||
|
await cdp.send('Emulation.setTimezoneOverride', { timezoneId: 'Asia/Seoul' });
|
||||||
await page.setViewportSize({ width: 1200, height: 900 });
|
await page.setViewportSize({ width: 1200, height: 900 });
|
||||||
await waitForMain(page);
|
await waitForMain(page);
|
||||||
|
|
||||||
const title = page.locator('[data-main-target="general"] .general-title').first();
|
const title = page.locator('[data-main-target="general"] .general-title').first();
|
||||||
await expect(title).toContainText('Administrator');
|
await expect(title).toContainText('Administrator');
|
||||||
await expect(title).toContainText('용장');
|
await expect(title).toContainText('용장');
|
||||||
await expect(title).toContainText('09:07:06');
|
await expect(title).toContainText('09:09:10');
|
||||||
await expect(title).not.toContainText('00:07');
|
await expect(title).not.toContainText('00:09');
|
||||||
const commandClock = page.locator('[data-main-target="commands"] [data-command-current-time]').first();
|
const commandClock = page.locator('[data-main-target="commands"] [data-command-current-time]').first();
|
||||||
await expect(commandClock).toHaveText('09:07:06');
|
await expect(commandClock).toHaveText('09:07:06');
|
||||||
await expect(commandClock).not.toHaveText('00:07');
|
await expect(commandClock).not.toHaveText('00:07');
|
||||||
|
await page.clock.runFor(1_000);
|
||||||
|
await expect(commandClock).toHaveText('09:07:07');
|
||||||
const generalCard = page.locator('[data-main-target="general"] [data-general-basic-card]').first();
|
const generalCard = page.locator('[data-main-target="general"] [data-general-basic-card]').first();
|
||||||
await expect(generalCard).toContainText('수비 함(훈사80)');
|
await expect(generalCard).toContainText('수비 함(훈사80)');
|
||||||
await expect(generalCard).toContainText('5 턴');
|
await expect(generalCard).toContainText('5 턴');
|
||||||
@@ -857,9 +868,9 @@ test('main general card and command clock render the next turn with second preci
|
|||||||
const target = resolve(artifactRoot);
|
const target = resolve(artifactRoot);
|
||||||
await mkdir(target, { recursive: true });
|
await mkdir(target, { recursive: true });
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
page.screenshot({ path: resolve(target, 'main-turn-time-seoul-desktop-1200.png'), fullPage: true }),
|
page.screenshot({ path: resolve(target, 'main-turn-time-local-desktop-1200.png'), fullPage: true }),
|
||||||
writeFile(
|
writeFile(
|
||||||
resolve(target, 'main-turn-time-seoul-desktop-1200.json'),
|
resolve(target, 'main-turn-time-local-desktop-1200.json'),
|
||||||
`${JSON.stringify({ title: desktopGeometry, commandClock: desktopClockGeometry }, null, 2)}\n`
|
`${JSON.stringify({ title: desktopGeometry, commandClock: desktopClockGeometry }, null, 2)}\n`
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
@@ -867,9 +878,9 @@ test('main general card and command clock render the next turn with second preci
|
|||||||
|
|
||||||
await page.setViewportSize({ width: 500, height: 900 });
|
await page.setViewportSize({ width: 500, height: 900 });
|
||||||
const mobileTitle = page.locator('[data-main-target="general"] .general-title').first();
|
const mobileTitle = page.locator('[data-main-target="general"] .general-title').first();
|
||||||
await expect(mobileTitle).toContainText('09:07:06');
|
await expect(mobileTitle).toContainText('09:09:10');
|
||||||
const mobileCommandClock = page.locator('[data-main-target="commands"] [data-command-current-time]').first();
|
const mobileCommandClock = page.locator('[data-main-target="commands"] [data-command-current-time]').first();
|
||||||
await expect(mobileCommandClock).toHaveText('09:07:06');
|
await expect(mobileCommandClock).toHaveText('09:07:07');
|
||||||
const mobileGeometry = {
|
const mobileGeometry = {
|
||||||
title: await mobileTitle.evaluate((element) => ({
|
title: await mobileTitle.evaluate((element) => ({
|
||||||
width: element.getBoundingClientRect().width,
|
width: element.getBoundingClientRect().width,
|
||||||
@@ -895,15 +906,23 @@ test('main general card and command clock render the next turn with second preci
|
|||||||
if (artifactRoot) {
|
if (artifactRoot) {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
page.screenshot({
|
page.screenshot({
|
||||||
path: resolve(artifactRoot, 'main-turn-time-seoul-mobile-500.png'),
|
path: resolve(artifactRoot, 'main-turn-time-local-mobile-500.png'),
|
||||||
fullPage: true,
|
fullPage: true,
|
||||||
}),
|
}),
|
||||||
writeFile(
|
writeFile(
|
||||||
resolve(artifactRoot, 'main-turn-time-seoul-mobile-500.json'),
|
resolve(artifactRoot, 'main-turn-time-local-mobile-500.json'),
|
||||||
`${JSON.stringify(mobileGeometry, null, 2)}\n`
|
`${JSON.stringify(mobileGeometry, null, 2)}\n`
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
state.clockMode = 'manual';
|
||||||
|
state.serverTime = '2026-08-13T00:08:30.000Z';
|
||||||
|
await page.reload();
|
||||||
|
const frozenClock = page.locator('[data-main-target="commands"] [data-command-current-time]').first();
|
||||||
|
await expect(frozenClock).toHaveText('09:08:30');
|
||||||
|
await page.clock.runFor(2_000);
|
||||||
|
await expect(frozenClock).toHaveText('09:08:30');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('pure NPC message senders are not rendered as reply targets', async ({ page }) => {
|
test('pure NPC message senders are not rendered as reply targets', async ({ page }) => {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue';
|
import { computed, onUnmounted, ref, watch } from 'vue';
|
||||||
import { addMinutes } from 'date-fns';
|
import { addMinutes } from 'date-fns';
|
||||||
import ReservedCommandEditor from '../command/ReservedCommandEditor.vue';
|
import ReservedCommandEditor from '../command/ReservedCommandEditor.vue';
|
||||||
import { formatSeoulTimeSeconds } from '../../utils/legacyDateTime';
|
import { formatLocalTimeSeconds } from '../../utils/legacyDateTime';
|
||||||
import type {
|
import type {
|
||||||
CommandMapData,
|
CommandMapData,
|
||||||
CommandMapLayout,
|
CommandMapLayout,
|
||||||
@@ -19,6 +19,8 @@ const props = defineProps<{
|
|||||||
currentYear?: number;
|
currentYear?: number;
|
||||||
currentMonth?: number;
|
currentMonth?: number;
|
||||||
turnTermMinutes?: number;
|
turnTermMinutes?: number;
|
||||||
|
serverTime?: string;
|
||||||
|
clockMode?: 'realtime' | 'manual';
|
||||||
autorunLimit?: number | null;
|
autorunLimit?: number | null;
|
||||||
storageKey?: string;
|
storageKey?: string;
|
||||||
mapData?: CommandMapData | null;
|
mapData?: CommandMapData | null;
|
||||||
@@ -56,16 +58,48 @@ const rows = computed<ReservedCommandRow[]>(() => {
|
|||||||
autonomous: props.autorunLimit != null && absoluteMonth <= props.autorunLimit - 1,
|
autonomous: props.autorunLimit != null && absoluteMonth <= props.autorunLimit - 1,
|
||||||
time: date
|
time: date
|
||||||
? term >= 5
|
? term >= 5
|
||||||
? `${String(date.getUTCHours()).padStart(2, '0')}:${String(date.getUTCMinutes()).padStart(2, '0')}`
|
? `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
|
||||||
: `${String(date.getUTCMinutes()).padStart(2, '0')}:${String(date.getUTCSeconds()).padStart(2, '0')}`
|
: `${String(date.getMinutes()).padStart(2, '0')}:${String(date.getSeconds()).padStart(2, '0')}`
|
||||||
: '--:--',
|
: '--:--',
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const currentTurnTime = computed(() =>
|
const currentServerTime = ref('--:--:--');
|
||||||
props.general?.turnTime ? formatSeoulTimeSeconds(props.general.turnTime) : '--:--:--'
|
let sampledServerTimeMs: number | null = null;
|
||||||
|
let sampledClientTimeMs = 0;
|
||||||
|
let serverClockTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
|
const updateServerClock = () => {
|
||||||
|
if (serverClockTimer !== undefined) clearTimeout(serverClockTimer);
|
||||||
|
serverClockTimer = undefined;
|
||||||
|
if (sampledServerTimeMs === null) {
|
||||||
|
currentServerTime.value = '--:--:--';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const projectedTime = new Date(
|
||||||
|
props.clockMode === 'manual' ? sampledServerTimeMs : sampledServerTimeMs + Date.now() - sampledClientTimeMs
|
||||||
|
);
|
||||||
|
currentServerTime.value = formatLocalTimeSeconds(projectedTime);
|
||||||
|
if (props.clockMode !== 'manual') {
|
||||||
|
serverClockTimer = setTimeout(updateServerClock, 1_000 - projectedTime.getMilliseconds());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [props.serverTime, props.clockMode] as const,
|
||||||
|
([serverTime]) => {
|
||||||
|
const parsed = serverTime ? new Date(serverTime).getTime() : Number.NaN;
|
||||||
|
sampledServerTimeMs = Number.isFinite(parsed) ? parsed : null;
|
||||||
|
sampledClientTimeMs = Date.now();
|
||||||
|
updateServerClock();
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (serverClockTimer !== undefined) clearTimeout(serverClockTimer);
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -75,7 +109,7 @@ const currentTurnTime = computed(() =>
|
|||||||
:command-table="props.commandTable"
|
:command-table="props.commandTable"
|
||||||
:loading="props.loading"
|
:loading="props.loading"
|
||||||
:storage-key="props.storageKey ?? `core2026:general:${props.general?.id ?? 0}`"
|
:storage-key="props.storageKey ?? `core2026:general:${props.general?.id ?? 0}`"
|
||||||
:current-time="currentTurnTime"
|
:current-time="currentServerTime"
|
||||||
:map-data="props.mapData"
|
:map-data="props.mapData"
|
||||||
:map-layout="props.mapLayout"
|
:map-layout="props.mapLayout"
|
||||||
@reserve-bulk="emit('set-general-turns', $event)"
|
@reserve-bulk="emit('set-general-turns', $event)"
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { computed } from 'vue';
|
|||||||
|
|
||||||
import SkeletonLines from '../ui/SkeletonLines.vue';
|
import SkeletonLines from '../ui/SkeletonLines.vue';
|
||||||
import LegacyProgressBar from '../ui/LegacyProgressBar.vue';
|
import LegacyProgressBar from '../ui/LegacyProgressBar.vue';
|
||||||
import { formatSeoulTimeSeconds } from '../../utils/legacyDateTime';
|
import { formatLocalTimeSeconds } from '../../utils/legacyDateTime';
|
||||||
import { legacyExperiencePercent, ratioPercent } from '../../utils/legacyProgress';
|
import { legacyExperiencePercent, ratioPercent } from '../../utils/legacyProgress';
|
||||||
import { DEFAULT_GENERAL_ICON_URL, resolveGeneralIconBackgroundImage } from '../../utils/generalIcon';
|
import { DEFAULT_GENERAL_ICON_URL, resolveGeneralIconBackgroundImage } from '../../utils/generalIcon';
|
||||||
import { configuredGameAssetUrl } from '../../utils/imageAssets';
|
import { configuredGameAssetUrl } from '../../utils/imageAssets';
|
||||||
@@ -232,7 +232,7 @@ const specialText = computed(() => {
|
|||||||
{{ props.general.officerLevelText }} | {{ props.general.generalType ?? '-' }} |
|
{{ props.general.officerLevelText }} | {{ props.general.generalType ?? '-' }} |
|
||||||
<span :style="{ color: injuryInfo.color }">{{ injuryInfo.text }}</span> 】
|
<span :style="{ color: injuryInfo.color }">{{ injuryInfo.text }}</span> 】
|
||||||
<span data-general-turn-time>{{
|
<span data-general-turn-time>{{
|
||||||
props.general.turnTime ? formatSeoulTimeSeconds(props.general.turnTime) : '-'
|
props.general.turnTime ? formatLocalTimeSeconds(props.general.turnTime) : '-'
|
||||||
}}</span>
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -7,3 +7,11 @@ export const formatSeoulHourMinute = (value: string | Date): string =>
|
|||||||
|
|
||||||
export const formatSeoulTimeSeconds = (value: string | Date): string =>
|
export const formatSeoulTimeSeconds = (value: string | Date): string =>
|
||||||
formatServerDateTime(value, { format: 'timeSeconds' });
|
formatServerDateTime(value, { format: 'timeSeconds' });
|
||||||
|
|
||||||
|
export const formatLocalTimeSeconds = (value: string | Date): string => {
|
||||||
|
const parsed = value instanceof Date ? value : new Date(value);
|
||||||
|
if (!Number.isFinite(parsed.getTime())) return '-';
|
||||||
|
return [parsed.getHours(), parsed.getMinutes(), parsed.getSeconds()]
|
||||||
|
.map((part) => String(part).padStart(2, '0'))
|
||||||
|
.join(':');
|
||||||
|
};
|
||||||
|
|||||||
@@ -205,6 +205,8 @@ watch(
|
|||||||
:current-year="lobbyInfo?.year"
|
:current-year="lobbyInfo?.year"
|
||||||
:current-month="lobbyInfo?.month"
|
:current-month="lobbyInfo?.month"
|
||||||
:turn-term-minutes="lobbyInfo?.turnTerm"
|
:turn-term-minutes="lobbyInfo?.turnTerm"
|
||||||
|
:server-time="lobbyInfo?.serverTime"
|
||||||
|
:clock-mode="lobbyInfo?.clockMode"
|
||||||
:autorun-limit="reservedGeneralAutorunLimit"
|
:autorun-limit="reservedGeneralAutorunLimit"
|
||||||
:map-data="worldMap"
|
:map-data="worldMap"
|
||||||
:map-layout="mapLayout"
|
:map-layout="mapLayout"
|
||||||
@@ -331,6 +333,8 @@ watch(
|
|||||||
:current-year="lobbyInfo?.year"
|
:current-year="lobbyInfo?.year"
|
||||||
:current-month="lobbyInfo?.month"
|
:current-month="lobbyInfo?.month"
|
||||||
:turn-term-minutes="lobbyInfo?.turnTerm"
|
:turn-term-minutes="lobbyInfo?.turnTerm"
|
||||||
|
:server-time="lobbyInfo?.serverTime"
|
||||||
|
:clock-mode="lobbyInfo?.clockMode"
|
||||||
:autorun-limit="reservedGeneralAutorunLimit"
|
:autorun-limit="reservedGeneralAutorunLimit"
|
||||||
:map-data="worldMap"
|
:map-data="worldMap"
|
||||||
:map-layout="mapLayout"
|
:map-layout="mapLayout"
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
|
|
||||||
import { formatSeoulDateTime, formatSeoulHourMinute, formatSeoulTimeSeconds } from '../src/utils/legacyDateTime.ts';
|
import {
|
||||||
|
formatLocalTimeSeconds,
|
||||||
|
formatSeoulDateTime,
|
||||||
|
formatSeoulHourMinute,
|
||||||
|
formatSeoulTimeSeconds,
|
||||||
|
} from '../src/utils/legacyDateTime.ts';
|
||||||
|
|
||||||
void test('formats API UTC timestamps in the server Seoul timezone', () => {
|
void test('formats API UTC timestamps in the server Seoul timezone', () => {
|
||||||
assert.equal(formatSeoulDateTime('2026-08-13T00:07:06.713Z'), '2026-08-13 09:07:06');
|
assert.equal(formatSeoulDateTime('2026-08-13T00:07:06.713Z'), '2026-08-13 09:07:06');
|
||||||
@@ -14,3 +19,13 @@ void test('keeps legacy timezone-less server timestamps unchanged', () => {
|
|||||||
assert.equal(formatSeoulHourMinute('2026-08-13 09:07:06'), '09:07');
|
assert.equal(formatSeoulHourMinute('2026-08-13 09:07:06'), '09:07');
|
||||||
assert.equal(formatSeoulTimeSeconds('2026-08-13 09:07:06'), '09:07:06');
|
assert.equal(formatSeoulTimeSeconds('2026-08-13 09:07:06'), '09:07:06');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
void test('formats an ISO instant with the client local clock', () => {
|
||||||
|
const instant = new Date('2026-08-13T00:07:06.713Z');
|
||||||
|
const expected = [instant.getHours(), instant.getMinutes(), instant.getSeconds()]
|
||||||
|
.map((part) => String(part).padStart(2, '0'))
|
||||||
|
.join(':');
|
||||||
|
assert.equal(formatLocalTimeSeconds(instant), expected);
|
||||||
|
assert.equal(formatLocalTimeSeconds(instant.toISOString()), expected);
|
||||||
|
assert.equal(formatLocalTimeSeconds('invalid'), '-');
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user