복구 2배속의 게임·실제 시간 표시 설정과 시계 빠른 전환 추가

This commit is contained in:
2026-09-10 04:22:18 +00:00
parent 3a5d4e7833
commit 73e1743a3d
26 changed files with 537 additions and 165 deletions
+158 -4
View File
@@ -840,12 +840,23 @@ const install = async (
page: Page, page: Page,
rejectGeneral = false, rejectGeneral = false,
commandTableResponse: unknown = commandTable, commandTableResponse: unknown = commandTable,
generalId = 1 generalId = 1,
recoveryClock?: {
serverTime: string;
serverWallTime: string;
clockRunning: boolean;
clockRecovery: { startsAt: string; endsAt: string } | null;
turnEngineRunning: boolean;
}
) => { ) => {
const requests: unknown[] = []; const requests: unknown[] = [];
const currentGeneralContext = { const currentGeneralContext = {
...generalContext, ...generalContext,
general: { ...generalContext.general, id: generalId }, general: {
...generalContext.general,
id: generalId,
...(recoveryClock ? { turnTime: '2026-09-10T01:20:00Z' } : {}),
},
}; };
const generalTurns = turns(30); const generalTurns = turns(30);
const nationTurns = turns(12); const nationTurns = turns(12);
@@ -929,7 +940,8 @@ const install = async (
myGeneral: { id: generalId, name: '장수' }, myGeneral: { id: generalId, name: '장수' },
year: 200, year: 200,
month: 1, month: 1,
turnTerm: 10, turnTerm: recoveryClock ? 60 : 10,
...recoveryClock,
userCnt: 1, userCnt: 1,
maxUserCnt: 100, maxUserCnt: 100,
npcCnt: 0, npcCnt: 0,
@@ -959,7 +971,19 @@ const install = async (
}); });
} }
if (name === 'turns.getCommandTable') return response(commandTableResponse); if (name === 'turns.getCommandTable') return response(commandTableResponse);
if (name === 'nation.getChiefCenter') return response(chiefCenter); if (name === 'nation.getChiefCenter')
return response(
recoveryClock
? {
...chiefCenter,
turnTermMinutes: 60,
chiefs: chiefCenter.chiefs.map((chief) => ({
...chief,
turnTime: '2026-09-10T01:20:00Z',
})),
}
: chiefCenter
);
if (name === 'turns.reserved.getGeneral') if (name === 'turns.reserved.getGeneral')
return response({ turns: generalTurns, revision: generalRevision, autorunLimit: 2403 }); return response({ turns: generalTurns, revision: generalRevision, autorunLimit: 2403 });
if (name === 'turns.reserved.getNation') return response({ turns: nationTurns, revision: nationRevision }); if (name === 'turns.reserved.getNation') return response({ turns: nationTurns, revision: nationRevision });
@@ -3341,3 +3365,133 @@ test('keeps the chief footer return button the same size as the top return butto
const mobile = await measure(); const mobile = await measure();
expect(mobile.bottom).toEqual(mobile.top); expect(mobile.bottom).toEqual(mobile.top);
}); });
const captureRecoveryControl = async (page: Page, selector: string, name: string) => {
const control = page.locator(selector).first();
await control.scrollIntoViewIfNeeded();
const read = () =>
control.evaluate((el) => {
const rect = el.getBoundingClientRect();
const style = getComputedStyle(el);
return {
rect: rect.toJSON(),
color: style.color,
background: style.backgroundColor,
font: style.font,
outline: style.outline,
cursor: style.cursor,
html: el.outerHTML,
overflow: el.scrollWidth > el.clientWidth,
};
});
const normal = await read();
expect(normal.rect.width).toBeGreaterThan(0);
expect(normal.rect.x).toBeGreaterThanOrEqual(0);
expect(normal.rect.right).toBeLessThanOrEqual(page.viewportSize()!.width + 1);
await control.hover();
const hover = await read();
await control.focus();
const focus = await read();
await page.mouse.down();
const active = await read();
await page.mouse.move(0, 0);
await page.mouse.up();
await writeFile(test.info().outputPath(`${name}.json`), JSON.stringify({ normal, hover, focus, active }, null, 2));
};
for (const width of [1200, 500]) {
test(`recovery clock preference and quick switches at ${width}px`, async ({ page }) => {
await page.setViewportSize({ width, height: 1000 });
await page.clock.setFixedTime(new Date('2026-09-10T02:00:00Z'));
const recoveryClock: NonNullable<Parameters<typeof install>[4]> = {
serverTime: '2026-09-10T01:00:00Z',
serverWallTime: '2026-09-10T02:00:00Z',
clockRunning: true,
turnEngineRunning: true,
clockRecovery: { startsAt: '2026-09-10T02:00:00Z', endsAt: '2026-09-10T03:00:00Z' },
};
await install(page, false, commandTable, 1, recoveryClock);
await page.goto(gamePath('/'));
const top = page.locator('.execution-status');
const clock = page.locator('[data-command-current-time]:visible').first();
await expect(top).toContainText('게임 시간');
await expect(top).toHaveCSS('color', 'rgb(255, 209, 128)');
const gameText = await clock.textContent();
await top.click();
await expect(top).toContainText('실제 시간');
await expect(top).toHaveCSS('color', 'rgb(165, 214, 167)');
await expect(clock).not.toHaveText(gameText!);
await expect(page.locator('[data-general-turn-time]:visible').first()).toContainText('02:10:00');
const editor = page.locator('[data-command-scope="general"]:visible').first();
await expect(editor).toContainText('02:40');
await expect(editor).toContainText('03:20');
await clock.focus();
await page.keyboard.press('Enter');
await expect(top).toContainText('게임 시간');
await editor.getByRole('button', { name: '고급 모드', exact: true }).click();
await clock.click();
await expect(top).toContainText('실제 시간');
await page.reload();
await expect(top).toContainText('실제 시간');
await page.evaluate(() => document.fonts.ready);
await page.screenshot({ path: test.info().outputPath(`recovery-main-${width}.png`), fullPage: true });
await writeFile(
test.info().outputPath(`recovery-main-${width}.json`),
JSON.stringify(
await top.evaluate((el) => {
const rect = el.getBoundingClientRect();
const style = getComputedStyle(el);
return {
text: el.textContent,
rect: rect.toJSON(),
color: style.color,
font: style.font,
html: el.outerHTML,
};
}),
null,
2
)
);
await captureRecoveryControl(page, '.execution-status', `recovery-main-states-${width}`);
await page.goto(gamePath('/chief-center'));
const chiefClock = page.locator('[data-command-current-time]:visible').first();
await expect(chiefClock).toHaveText('11:00:00');
await chiefClock.click();
await expect(chiefClock).toHaveText('10:00:00');
const chief = page.locator('[data-command-scope="nation"]:visible').first();
await chief.getByRole('button', { name: '고급 모드', exact: true }).click();
await chiefClock.click();
await expect(chiefClock).toHaveText('11:00:00');
await page.screenshot({ path: test.info().outputPath(`recovery-chief-${width}.png`), fullPage: true });
await captureRecoveryControl(page, '[data-command-current-time]:visible', `recovery-chief-states-${width}`);
// At the end boundary, 1x clicks cannot change the preference.
recoveryClock.clockRecovery = null;
recoveryClock.serverTime = '2026-09-10T03:00:00Z';
recoveryClock.serverWallTime = '2026-09-10T03:00:00Z';
await page.clock.setFixedTime(new Date('2026-09-10T03:00:00Z'));
await expect(chiefClock).toBeDisabled();
await expect(chiefClock).toHaveText('12:00:00');
await chiefClock.evaluate((element: HTMLButtonElement) => element.click());
await expect(chiefClock).toHaveAttribute('title', '실제 시간 기준');
await page.goto(gamePath('/my-settings'));
const setting = page.getByRole('radiogroup', { name: '가속 시 시간 표시 기준' });
await expect(setting.getByRole('radio', { name: '실제 시간 기준', exact: true })).toBeChecked();
await setting.getByRole('radio', { name: '게임 시간 기준', exact: true }).check();
await page.screenshot({ path: test.info().outputPath(`recovery-settings-${width}.png`), fullPage: true });
await captureRecoveryControl(
page,
'[aria-label="가속 시 시간 표시 기준"]',
`recovery-settings-states-${width}`
);
await page.reload();
await expect(setting.getByRole('radio', { name: '게임 시간 기준', exact: true })).toBeChecked();
await page.goto(gamePath('/'));
await expect(top).toHaveCSS('color', 'rgb(0, 255, 255)');
await expect(top).toBeDisabled();
recoveryClock.turnEngineRunning = false;
await page.reload();
await expect(top).toHaveCSS('color', 'rgb(255, 0, 255)');
await expect(clock).toBeDisabled();
});
}
+2
View File
@@ -1,10 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { useClockDisplayRefresh } from './composables/useClockDisplayRefresh';
import { RouterView } from 'vue-router'; import { RouterView } from 'vue-router';
import GameServerConnectionNotice from './components/ui/GameServerConnectionNotice.vue'; import GameServerConnectionNotice from './components/ui/GameServerConnectionNotice.vue';
import GameFeedbackLayer from './components/ui/GameFeedbackLayer.vue'; import GameFeedbackLayer from './components/ui/GameFeedbackLayer.vue';
import { useDeploymentVersionNotice } from './composables/useDeploymentVersionNotice'; import { useDeploymentVersionNotice } from './composables/useDeploymentVersionNotice';
useDeploymentVersionNotice(); useDeploymentVersionNotice();
useClockDisplayRefresh();
</script> </script>
<template> <template>
@@ -1,5 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed } from 'vue';
import { useClockDisplay } from '../../composables/useClockDisplay';
import { formatSeoulTimeSeconds } from '../../utils/legacyDateTime';
const { time } = useClockDisplay();
const currentTime = computed(() => (time.value ? formatSeoulTimeSeconds(time.value) : '--:--:--'));
import ReservedCommandEditor from '../command/ReservedCommandEditor.vue'; import ReservedCommandEditor from '../command/ReservedCommandEditor.vue';
import type { import type {
CommandMapData, CommandMapData,
@@ -50,7 +54,7 @@ const reserveBulk = (entries: CommandPatternEntry[], complete?: ReservationCompl
:mobile="props.mobile" :mobile="props.mobile"
:title="props.officerLevelText" :title="props.officerLevelText"
:name="props.name" :name="props.name"
:current-time="props.rows[0]?.time" :current-time="currentTime"
:map-data="props.mapData" :map-data="props.mapData"
:map-layout="props.mapLayout" :map-layout="props.mapLayout"
@reserve-bulk="reserveBulk" @reserve-bulk="reserveBulk"
@@ -1,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { useClockDisplay } from '../../composables/useClockDisplay';
const { accelerated, label: clockLabel, toggle: toggleClock, mode: clockDisplayMode } = useClockDisplay();
import { computed, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue'; import { computed, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue';
import CommandArgumentForm from '../main/CommandArgumentForm.vue'; import CommandArgumentForm from '../main/CommandArgumentForm.vue';
import CommandSelectForm from '../main/CommandSelectForm.vue'; import CommandSelectForm from '../main/CommandSelectForm.vue';
@@ -382,7 +384,18 @@ const clickOutsideMenu = (event: Event) => {
><span>{{ props.title }}</span> ><span>{{ props.title }}</span>
</div> </div>
<button type="button" @click="editMode = !editMode">{{ editMode ? '일반 모드' : '고급 모드' }}</button> <button type="button" @click="editMode = !editMode">{{ editMode ? '일반 모드' : '고급 모드' }}</button>
<div class="clock" data-command-current-time>{{ props.currentTime }}</div> <button
type="button"
class="clock"
data-command-current-time
:class="{ 'clock--real': accelerated && clockDisplayMode === 'real' }"
:disabled="!accelerated"
:title="accelerated ? `${clockLabel} · 클릭하여 변경` : clockLabel"
:aria-label="`현재 시각 · ${clockLabel}`"
@click="toggleClock"
>
{{ props.currentTime }}
</button>
<details class="legacy-menu"> <details class="legacy-menu">
<summary>반복</summary> <summary>반복</summary>
<div class="menu-items"> <div class="menu-items">
@@ -881,10 +894,17 @@ const clickOutsideMenu = (event: Event) => {
place-items: center; place-items: center;
padding: 4px; padding: 4px;
} }
.clock { .control-pad > .clock {
background: #345c85; background: #345c85;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
.clock:disabled {
opacity: 1;
cursor: default;
}
.control-pad > .clock--real {
background: #386b45;
}
.legacy-menu { .legacy-menu {
position: relative; position: relative;
min-width: 0; min-width: 0;
@@ -1,10 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onUnmounted, ref, watch } from 'vue'; import { computed } from 'vue';
import { useClockDisplay } from '../../composables/useClockDisplay';
import { addMinutes } from 'date-fns'; import { addMinutes } from 'date-fns';
import ReservedCommandEditor from '../command/ReservedCommandEditor.vue'; import ReservedCommandEditor from '../command/ReservedCommandEditor.vue';
import { generalTurnEditorModeStorageKey } from '../command/commandQueue'; import { generalTurnEditorModeStorageKey } from '../command/commandQueue';
import { formatLocalDateTime, formatLocalTimeSeconds } from '../../utils/legacyDateTime'; import { formatLocalDateTime, formatLocalTimeSeconds } from '../../utils/legacyDateTime';
import { projectServerClock, sampleServerClock, type SampledServerClock } from '../../utils/serverClockProjection';
import { gameFrontendRuntimeConfig } from '../../config/runtimeConfig'; import { gameFrontendRuntimeConfig } from '../../config/runtimeConfig';
import type { import type {
CommandMapData, CommandMapData,
@@ -14,6 +15,9 @@ import type {
ReservedCommandRow, ReservedCommandRow,
} from '../command/types'; } from '../command/types';
const { projectTime, time } = useClockDisplay();
const currentServerTime = computed(() => (time.value ? formatLocalTimeSeconds(time.value) : '--:--:--'));
type ReservationCompletion = (success: boolean) => void; type ReservationCompletion = (success: boolean) => void;
const props = defineProps<{ const props = defineProps<{
@@ -69,7 +73,7 @@ const rows = computed<ReservedCommandRow[]>(() => {
const term = props.turnTermMinutes ?? 0; const term = props.turnTermMinutes ?? 0;
return (props.reservedGeneralTurns ?? []).map((turn, offset) => { return (props.reservedGeneralTurns ?? []).map((turn, offset) => {
const absoluteMonth = firstReservedMonth.value + offset; const absoluteMonth = firstReservedMonth.value + offset;
const date = base && Number.isFinite(base.getTime()) ? addMinutes(base, offset * term) : null; const date = base && Number.isFinite(base.getTime()) ? projectTime(addMinutes(base, offset * term)) : null;
return { return {
...turn, ...turn,
args: turn.args ?? {}, args: turn.args ?? {},
@@ -100,62 +104,9 @@ const autonomousUntil = computed(() => {
base && Number.isFinite(base.getTime()) base && Number.isFinite(base.getTime())
? addMinutes(base, (lastAutonomousMonth - currentAbsoluteMonth) * term) ? addMinutes(base, (lastAutonomousMonth - currentAbsoluteMonth) * term)
: null; : null;
const currentTimeLabel = expiresAt ? formatLocalDateTime(expiresAt) : '현재시각 확인 불가'; const currentTimeLabel = expiresAt ? formatLocalDateTime(projectTime(expiresAt)) : '현재시각 확인 불가';
return `${untilYear}${untilMonth}月 · ${currentTimeLabel}까지`; return `${untilYear}${untilMonth}月 · ${currentTimeLabel}까지`;
}); });
const currentServerTime = ref('--:--:--');
const MAX_SERVER_CLOCK_TIMER_DELAY_MS = 60_000;
let serverClockSample: SampledServerClock | null = null;
let serverClockTimer: ReturnType<typeof setTimeout> | undefined;
const updateServerClock = () => {
if (serverClockTimer !== undefined) clearTimeout(serverClockTimer);
serverClockTimer = undefined;
if (serverClockSample === null) {
currentServerTime.value = '--:--:--';
return;
}
const { clientElapsedMs, time: projectedTime, rate } = projectServerClock(serverClockSample);
currentServerTime.value = formatLocalTimeSeconds(projectedTime);
if (serverClockSample.clockMode !== 'manual' && serverClockSample.startDelayMs !== null) {
const untilStartMs = serverClockSample.startDelayMs - clientElapsedMs;
serverClockTimer = setTimeout(
updateServerClock,
untilStartMs > 0
? Math.min(untilStartMs, MAX_SERVER_CLOCK_TIMER_DELAY_MS)
: (1_000 - projectedTime.getMilliseconds()) / rate
);
}
};
watch(
() =>
[
props.serverTime,
props.serverWallTime,
props.clockMode,
props.clockRunning,
props.clockStartsAt,
props.clockRecovery,
] as const,
([serverTime, serverWallTime, clockMode, clockRunning, clockStartsAt, clockRecovery]) => {
serverClockSample = sampleServerClock({
serverTime,
serverWallTime,
clockMode,
clockRunning,
clockStartsAt,
clockRecovery,
});
updateServerClock();
},
{ immediate: true }
);
onUnmounted(() => {
if (serverClockTimer !== undefined) clearTimeout(serverClockTimer);
});
</script> </script>
<template> <template>
@@ -1,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { useClockDisplay } from '../../composables/useClockDisplay';
const { projectTime } = useClockDisplay();
import { computed } from 'vue'; import { computed } from 'vue';
import SkeletonLines from '../ui/SkeletonLines.vue'; import SkeletonLines from '../ui/SkeletonLines.vue';
@@ -257,7 +259,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 ? formatLocalTimeSeconds(props.general.turnTime) : '-' props.general.turnTime ? formatLocalTimeSeconds(projectTime(props.general.turnTime)) : '-'
}}</span> }}</span>
</div> </div>
@@ -1,13 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime'; import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime';
import { computed, onUnmounted, ref, watch } from 'vue'; import { computed, watch } from 'vue';
import { resolveTournamentStageName } from '../../utils/tournamentStatus'; import { resolveTournamentStageName } from '../../utils/tournamentStatus';
import { import { receiveClockSample, useClockDisplay } from '../../composables/useClockDisplay';
millisecondsUntilNextMinute,
projectServerClock,
sampleServerClock,
type SampledServerClock,
} from '../../utils/serverClockProjection';
const props = defineProps<{ const props = defineProps<{
tournamentStage: number; tournamentStage: number;
@@ -33,99 +28,53 @@ const props = defineProps<{
}>(); }>();
const tournamentStatus = computed(() => resolveTournamentStageName(props.tournamentStage)); const tournamentStatus = computed(() => resolveTournamentStageName(props.tournamentStage));
const currentServerTime = ref('기록 없음'); const { time, accelerated: recovering, mode, label, toggle, engineRunning } = useClockDisplay();
const hasServerClock = ref(false); const currentServerTime = computed(() =>
const recovering = ref(false); formatServerDateTime(time.value, { format: 'monthDayTime', fallback: '기록 없음' })
const turnEngineStopped = computed(() => props.turnEngineRunning === false); );
const turnEngineStatusUnknown = computed(() => typeof props.turnEngineRunning !== 'boolean'); const hasServerClock = computed(() => time.value !== null);
const turnEngineStopped = computed(() => engineRunning.value === false);
const turnEngineStatusUnknown = computed(() => typeof engineRunning.value !== 'boolean');
const serverClockTitle = computed(() => { const serverClockTitle = computed(() => {
if (!hasServerClock.value) return '서버 시각을 아직 받지 못했습니다.'; if (!hasServerClock.value) return '서버 시각을 아직 받지 못했습니다.';
if (turnEngineStopped.value) return '턴 엔진이 정지하여 현재 시각 보정을 멈췄습니다.'; if (turnEngineStopped.value) return '턴 엔진이 정지하여 현재 시각 보정을 멈췄습니다.';
if (turnEngineStatusUnknown.value) return '턴 엔진 진행 상태를 확인하지 못했습니다.'; if (turnEngineStatusUnknown.value) return '턴 엔진 진행 상태를 확인하지 못했습니다.';
return undefined; return recovering.value ? `${label.value} · 클릭하여 변경` : label.value;
}); });
let serverClockSample: SampledServerClock | null = null;
let serverClockTimer: ReturnType<typeof setTimeout> | undefined;
const updateServerClock = () => {
if (serverClockTimer !== undefined) clearTimeout(serverClockTimer);
serverClockTimer = undefined;
if (serverClockSample === null) {
currentServerTime.value = '기록 없음';
hasServerClock.value = false;
return;
}
const now = Date.now();
const projection = projectServerClock(serverClockSample, now);
recovering.value = projection.rate === 2;
currentServerTime.value = formatServerDateTime(projection.time, {
format: 'monthDayTime',
fallback: '기록 없음',
});
hasServerClock.value = true;
if (props.turnEngineRunning !== true) return;
const nextDelays: number[] = [];
if (serverClockSample.clockMode !== 'manual' && serverClockSample.startDelayMs !== null) {
const untilStartMs = serverClockSample.startDelayMs - projection.clientElapsedMs;
nextDelays.push(
untilStartMs > 0 ? untilStartMs : millisecondsUntilNextMinute(projection.time) / projection.rate
);
}
for (const boundary of [serverClockSample.recoveryStartDelayMs, serverClockSample.recoveryEndDelayMs]) {
if (boundary !== undefined && boundary > projection.clientElapsedMs)
nextDelays.push(boundary - projection.clientElapsedMs);
}
if (nextDelays.length === 0) return;
serverClockTimer = setTimeout(updateServerClock, Math.max(1, Math.min(...nextDelays)));
};
watch( watch(
() => () => [
[
props.serverTime, props.serverTime,
props.serverWallTime, props.serverWallTime,
props.clockMode, props.clockMode,
props.clockRunning, props.clockRunning,
props.clockStartsAt, props.clockStartsAt,
props.clockRecovery, props.clockRecovery,
] as const, ],
([serverTime, serverWallTime, clockMode, clockRunning, clockStartsAt, clockRecovery]) => { () => receiveClockSample(props),
serverClockSample = sampleServerClock({
serverTime,
serverWallTime,
clockMode,
clockRunning,
clockStartsAt,
clockRecovery,
});
updateServerClock();
},
{ immediate: true } { immediate: true }
); );
watch(() => props.turnEngineRunning, updateServerClock);
onUnmounted(() => {
if (serverClockTimer !== undefined) clearTimeout(serverClockTimer);
});
</script> </script>
<template> <template>
<section class="front-status" aria-label="접속 현황과 국가 방침"> <section class="front-status" aria-label="접속 현황과 국가 방침">
<div class="activity-status" aria-label="현재 시각, 토너먼트와 설문 진행 현황"> <div class="activity-status" aria-label="현재 시각, 토너먼트와 설문 진행 현황">
<div <button
type="button"
:disabled="!recovering || turnEngineStopped"
class="status-row execution-status" class="status-row execution-status"
:class="{ :class="{
'execution-status--game': recovering && mode === 'game',
'execution-status--real': recovering && mode === 'real',
'execution-status--empty': !hasServerClock, 'execution-status--empty': !hasServerClock,
'execution-status--stopped': hasServerClock && turnEngineStopped, 'execution-status--stopped': hasServerClock && turnEngineStopped,
'execution-status--unknown': hasServerClock && turnEngineStatusUnknown, 'execution-status--unknown': hasServerClock && turnEngineStatusUnknown,
}" }"
:title="serverClockTitle" :title="serverClockTitle"
@click="toggle"
> >
현재 시각: {{ currentServerTime }}<span v-if="recovering"> · 복구 2배속</span> 현재 시각: {{ currentServerTime
</div> }}<span v-if="recovering"> · 2배속 · {{ mode === 'real' ? '실제 시간' : '게임 시간' }}</span>
</button>
<div class="status-row tournament-status"> <div class="status-row tournament-status">
<RouterLink to="/tournament"> <RouterLink to="/tournament">
<span class="tournament-label">토너먼트: </span>{{ tournamentStatus }} <span class="tournament-label">토너먼트: </span>{{ tournamentStatus }}
@@ -208,9 +157,31 @@ onUnmounted(() => {
} }
.execution-status { .execution-status {
background: transparent;
border-right: 0;
border-bottom: 0;
border-left: 0;
font: inherit;
cursor: pointer;
color: cyan; color: cyan;
} }
.execution-status > span {
display: block;
white-space: nowrap;
font-size: 12px;
}
.execution-status:disabled {
opacity: 1;
cursor: default;
}
.execution-status--game {
color: #ffd180;
}
.execution-status--real {
color: #a5d6a7;
}
.execution-status--empty { .execution-status--empty {
color: magenta; color: magenta;
} }
@@ -0,0 +1,98 @@
import { computed, ref, shallowRef } from 'vue';
import { useStorage } from '@vueuse/core';
import { formatServerDateTime, type ServerDateTimeOptions } from '@sammo-ts/common/time/ServerDateTime';
import { gameFrontendRuntimeConfig } from '../config/runtimeConfig';
import {
projectRecoveryTime,
projectServerClock,
sampleServerClock,
type ServerClockProjectionInput,
} from '../utils/serverClockProjection';
export const clockDisplayStorageKey = `sammo-clock-display:${gameFrontendRuntimeConfig.profile}:${gameFrontendRuntimeConfig.appBasePath}`;
const storedMode = useStorage<string>(clockDisplayStorageKey, 'game');
const mode = computed({
get: () => (storedMode.value === 'real' ? 'real' : 'game'),
set: (value: string) => {
storedMode.value = value === 'real' ? 'real' : 'game';
},
});
const sample = shallowRef<ReturnType<typeof sampleServerClock>>(null);
const now = ref(Date.now());
const engineRunning = ref<boolean | null>(null);
const haltedAt = ref<number | null>(null);
export const receiveClockEngineState = (running: boolean | null): void => {
engineRunning.value = running;
haltedAt.value = running === false ? (haltedAt.value ?? Date.now()) : null;
};
export const receiveClockSample = (
input: ServerClockProjectionInput & { turnEngineRunning?: boolean | null }
): void => {
if (!input.serverTime) return;
const wallTime = input.serverWallTime ? new Date(input.serverWallTime).getTime() : undefined;
// 메인 화면의 이전 표본을 heartbeat가 재전달해도 더 최신 lobby 표본을 되감지 않는다.
if (
wallTime !== undefined &&
sample.value?.serverWallTimeMs !== undefined &&
wallTime < sample.value.serverWallTimeMs
)
return;
if (
sample.value &&
wallTime !== undefined &&
wallTime === sample.value.serverWallTimeMs &&
new Date(input.serverTime).getTime() === sample.value.serverTimeMs &&
input.turnEngineRunning === engineRunning.value
)
return;
receiveClockEngineState(input.turnEngineRunning ?? null);
sample.value = sampleServerClock(
input.turnEngineRunning === false ? { ...input, clockRunning: false, clockStartsAt: null } : input
);
now.value = Date.now();
};
export const advanceClockDisplay = (): void => {
now.value = Date.now();
};
export const clockSampleIsStale = (): boolean =>
!sample.value || Date.now() - sample.value.sampledClientTimeMs >= 30_000;
const projection = computed(() =>
sample.value ? projectServerClock(sample.value, haltedAt.value ?? now.value) : null
);
const accelerated = computed(() => projection.value?.rate === 2 && engineRunning.value !== false);
const label = computed(() => (mode.value === 'real' ? '실제 시간 기준' : '게임 시간 기준'));
const time = computed(() => {
const projected = projection.value?.time;
if (!projected) return null;
return mode.value === 'real' && accelerated.value ? projectRecoveryTime(sample.value, projected) : projected;
});
const toggle = (): void => {
if (accelerated.value) mode.value = mode.value === 'real' ? 'game' : 'real';
};
const projectTime = (value: string | Date): Date => {
// timezone 없는 API 값은 기존 고정 UTC+9 서버 벽시계 계약을 유지한다.
const normalized =
typeof value === 'string' && /^\d{4}-\d\d-\d\d[ T]\d\d:\d\d(?::\d\d(?:\.\d+)?)?$/.test(value)
? `${value.replace(' ', 'T')}+09:00`
: value;
const date = normalized instanceof Date ? normalized : new Date(normalized);
return mode.value === 'real' ? projectRecoveryTime(sample.value, date) : date;
};
const formatTime = (value: string | Date | null | undefined, options?: ServerDateTimeOptions): string =>
formatServerDateTime(value ? projectTime(value) : value, options);
export const useClockDisplay = () => ({
mode,
label,
time,
accelerated,
toggle,
projectTime,
formatTime,
engineRunning,
});
@@ -0,0 +1,37 @@
import { onMounted, onUnmounted } from 'vue';
import { trpc } from '../utils/trpc';
import { advanceClockDisplay, clockSampleIsStale } from './useClockDisplay';
export const useClockDisplayRefresh = (): void => {
let timer: ReturnType<typeof setInterval> | undefined;
let pending = false;
let lastAttempt = -Infinity;
const refresh = async () => {
advanceClockDisplay();
if (
Date.now() - lastAttempt < 30_000 ||
pending ||
document.visibilityState !== 'visible' ||
!clockSampleIsStale()
)
return;
pending = true;
lastAttempt = Date.now();
try {
await trpc.lobby.info.query();
} catch {
/* 다음 표본으로 복구한다. */
} finally {
pending = false;
}
};
onMounted(() => {
void refresh();
timer = setInterval(() => {
void refresh();
}, 250);
});
onUnmounted(() => {
if (timer) clearInterval(timer);
});
};
@@ -1,3 +1,4 @@
import { receiveClockEngineState } from '../composables/useClockDisplay';
import { computed, ref, toRaw, watch } from 'vue'; import { computed, ref, toRaw, watch } from 'vue';
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { import {
@@ -557,6 +558,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
const applyTurnEngineRunning = (turnEngineRunning: boolean | null | undefined) => { const applyTurnEngineRunning = (turnEngineRunning: boolean | null | undefined) => {
if (turnEngineRunning === undefined || !lobbyInfo.value) return; if (turnEngineRunning === undefined || !lobbyInfo.value) return;
receiveClockEngineState(turnEngineRunning);
lobbyInfo.value = structurallyShare(lobbyInfo.value, { lobbyInfo.value = structurallyShare(lobbyInfo.value, {
...lobbyInfo.value, ...lobbyInfo.value,
turnEngineRunning, turnEngineRunning,
@@ -10,6 +10,7 @@ export type ServerClockProjectionInput = {
export type SampledServerClock = { export type SampledServerClock = {
serverTimeMs: number; serverTimeMs: number;
sampledClientTimeMs: number; sampledClientTimeMs: number;
serverWallTimeMs?: number;
clockMode: 'realtime' | 'manual'; clockMode: 'realtime' | 'manual';
startDelayMs: number | null; startDelayMs: number | null;
recoveryStartDelayMs?: number; recoveryStartDelayMs?: number;
@@ -49,6 +50,7 @@ export const sampleServerClock = (
return { return {
serverTimeMs, serverTimeMs,
sampledClientTimeMs, sampledClientTimeMs,
...(wallSample !== null ? { serverWallTimeMs: wallSample } : {}),
clockMode: input.clockMode ?? 'realtime', clockMode: input.clockMode ?? 'realtime',
startDelayMs, startDelayMs,
...(wallSample !== null && recoveryStart !== null && recoveryEnd !== null && recoveryEnd > recoveryStart ...(wallSample !== null && recoveryStart !== null && recoveryEnd !== null && recoveryEnd > recoveryStart
@@ -93,3 +95,25 @@ export const millisecondsUntilNextMinute = (time: Date): number => {
const remainder = ((time.getTime() % 60_000) + 60_000) % 60_000; const remainder = ((time.getTime() % 60_000) + 60_000) % 60_000;
return remainder === 0 ? 60_000 : 60_000 - remainder; return remainder === 0 ? 60_000 : 60_000 - remainder;
}; };
// 복구 종료 좌표를 기준으로 역산한다. 종료 이후의 턴에는 2배속을 적용하지 않는다.
export const projectRecoveryTime = (sample: SampledServerClock | null, gameTime: Date): Date => {
if (
!sample ||
sample.clockMode === 'manual' ||
sample.startDelayMs === null ||
sample.serverWallTimeMs === undefined ||
sample.recoveryStartDelayMs === undefined ||
sample.recoveryEndDelayMs === undefined
)
return gameTime;
const endDelay = sample.recoveryEndDelayMs;
const endGame = projectServerClock(sample, sample.sampledClientTimeMs + Math.max(0, endDelay)).time.getTime();
const endWall = sample.serverWallTimeMs + endDelay;
const span = endDelay - sample.recoveryStartDelayMs;
const startGame = endGame - 2 * span;
const target = gameTime.getTime();
// 이전 기록은 이 복구 창으로 실제 발생 시각을 알 수 없으므로 그대로 둔다.
if (target < startGame) return gameTime;
return new Date(Math.ceil(target <= endGame ? endWall - (endGame - target) / 2 : endWall + target - endGame));
};
+21
View File
@@ -1,5 +1,8 @@
import { trpcJsonBodyHttpClientOptions } from '@sammo-ts/common/http/trpcTransport'; import { trpcJsonBodyHttpClientOptions } from '@sammo-ts/common/http/trpcTransport';
import { REALTIME_ACCESS_GRANT_HEADER } from '@sammo-ts/common/realtime/types'; import { REALTIME_ACCESS_GRANT_HEADER } from '@sammo-ts/common/realtime/types';
import { observable } from '@trpc/server/observable';
import { receiveClockSample } from '../composables/useClockDisplay';
import type { ServerClockProjectionInput } from './serverClockProjection';
import { createTRPCProxyClient, httpBatchLink } from '@trpc/client'; import { createTRPCProxyClient, httpBatchLink } from '@trpc/client';
import type { AppRouter } from '@sammo-ts/game-api'; import type { AppRouter } from '@sammo-ts/game-api';
import { gameFrontendRuntimeConfig } from '../config/runtimeConfig'; import { gameFrontendRuntimeConfig } from '../config/runtimeConfig';
@@ -25,6 +28,24 @@ const getGameToken = (): string | null => {
export const trpc = createTRPCProxyClient<AppRouter>({ export const trpc = createTRPCProxyClient<AppRouter>({
links: [ links: [
() =>
({ op, next }) =>
observable((observer) =>
next(op).subscribe({
next(value) {
if (op.path === 'lobby.info' && 'data' in value.result && value.result.data) {
receiveClockSample(
value.result.data as ServerClockProjectionInput & {
turnEngineRunning?: boolean | null;
}
);
}
observer.next(value);
},
error: (error) => observer.error(error),
complete: () => observer.complete(),
})
),
httpBatchLink({ httpBatchLink({
url: gameFrontendRuntimeConfig.gameApiUrl, url: gameFrontendRuntimeConfig.gameApiUrl,
...trpcJsonBodyHttpClientOptions, ...trpcJsonBodyHttpClientOptions,
+5 -3
View File
@@ -1,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { useClockDisplay } from '../composables/useClockDisplay';
const { formatTime: formatGameTime } = useClockDisplay();
import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime'; import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime';
import { computed, onMounted, reactive, ref, watch } from 'vue'; import { computed, onMounted, reactive, ref, watch } from 'vue';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
@@ -42,8 +44,8 @@ const resolveErrorMessage = (value: unknown): string => {
const formatNumber = (value: number | null | undefined): string => (value ?? 0).toLocaleString(); const formatNumber = (value: number | null | undefined): string => (value ?? 0).toLocaleString();
const displayCode = (value: string | null | undefined): string => const displayCode = (value: string | null | undefined): string =>
!value || /^\d+$/u.test(value) ? '-' : value.replace(/^che_(?:event_)?/u, ''); !value || /^\d+$/u.test(value) ? '-' : value.replace(/^che_(?:event_)?/u, '');
const cutDateTime = (value: string | null | undefined, showSecond = false): string => { const cutDateTime = (value: string | null | undefined, showSecond = false, gameTime = true): string => {
return formatServerDateTime(value, { return (gameTime ? formatGameTime : formatServerDateTime)(value, {
format: showSecond ? 'monthDayTimeSeconds' : 'monthDayTime', format: showSecond ? 'monthDayTimeSeconds' : 'monthDayTime',
fallback: '-', fallback: '-',
}); });
@@ -413,7 +415,7 @@ onMounted(() => {
<div v-for="bid in uniqueDetail.bids" :key="bid.id" class="bid-row"> <div v-for="bid in uniqueDetail.bids" :key="bid.id" class="bid-row">
<span :class="{ 'is-me': bid.isCaller }">{{ bid.bidderName }}</span> <span :class="{ 'is-me': bid.isCaller }">{{ bid.bidderName }}</span>
<span class="tnum">{{ formatNumber(bid.amount) }}</span> <span class="tnum">{{ formatNumber(bid.amount) }}</span>
<time class="tnum">{{ cutDateTime(bid.eventAt) }}</time> <time class="tnum">{{ cutDateTime(bid.eventAt, false, false) }}</time>
</div> </div>
<template v-if="uniqueDetail.auction.status === 'OPEN'"> <template v-if="uniqueDetail.auction.status === 'OPEN'">
<h3 class="subsection-title bg1">입찰하기</h3> <h3 class="subsection-title bg1">입찰하기</h3>
@@ -1,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { useClockDisplay } from '../composables/useClockDisplay';
const { formatTime: formatGameTime } = useClockDisplay();
import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime'; import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime';
import { computed, onMounted, reactive, ref, watch } from 'vue'; import { computed, onMounted, reactive, ref, watch } from 'vue';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
@@ -130,7 +132,7 @@ const selectedGeneral = computed(() => {
const formatGeneralLabel = (general: GeneralEntry): string => { const formatGeneralLabel = (general: GeneralEntry): string => {
const name = general.officerLevel > 4 ? `*${general.name}*` : general.name; const name = general.officerLevel > 4 ? `*${general.name}*` : general.name;
const time = formatServerDateTime(general.turnTime, { format: 'hourMinute', fallback: '--:--' }); const time = formatGameTime(general.turnTime, { format: 'hourMinute', fallback: '--:--' });
if (orderBy.value === 'recentWar') { if (orderBy.value === 'recentWar') {
return `${name} (${formatServerDateTime(general.recentWar, { format: 'hourMinute', fallback: '--:--' })})`; return `${name} (${formatServerDateTime(general.recentWar, { format: 'hourMinute', fallback: '--:--' })})`;
} }
+3 -2
View File
@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime'; import { useClockDisplay } from '../composables/useClockDisplay';
const { formatTime: formatGameTime } = useClockDisplay();
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'; import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue';
import TournamentBracket from '../components/tournament/TournamentBracket.vue'; import TournamentBracket from '../components/tournament/TournamentBracket.vue';
@@ -53,7 +54,7 @@ const ratio = (id: number) => {
return amount ? (totalAmount.value / amount).toFixed(2) : '0'; return amount ? (totalAmount.value / amount).toFixed(2) : '0';
}; };
const openingTime = computed(() => const openingTime = computed(() =>
formatServerDateTime(snapshot.value?.state?.nextAt, { format: 'hourMinute', fallback: '--:--' }) formatGameTime(snapshot.value?.state?.nextAt, { format: 'hourMinute', fallback: '--:--' })
); );
const selectedRatio = computed(() => { const selectedRatio = computed(() => {
const targetId = selectedTarget.value?.id; const targetId = selectedTarget.value?.id;
@@ -1,9 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { useClockDisplay } from '../composables/useClockDisplay';
const { formatTime: formatGameTime } = useClockDisplay();
import { computed, onMounted, ref, watch } from 'vue'; import { computed, onMounted, ref, watch } from 'vue';
import { useMediaQuery } from '@vueuse/core'; import { useMediaQuery } from '@vueuse/core';
import { addMinutes } from 'date-fns'; import { addMinutes } from 'date-fns';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime';
import SkeletonLines from '../components/ui/SkeletonLines.vue'; import SkeletonLines from '../components/ui/SkeletonLines.vue';
import ChiefTurnCard from '../components/chief/ChiefTurnCard.vue'; import ChiefTurnCard from '../components/chief/ChiefTurnCard.vue';
import ChiefCommandEditor from '../components/chief/ChiefCommandEditor.vue'; import ChiefCommandEditor from '../components/chief/ChiefCommandEditor.vue';
@@ -220,8 +221,8 @@ const buildTurnRows = (chief: ChiefEntry): TurnRow[] => {
baseTime && Number.isFinite(turnTermMinutes) ? addMinutes(baseTime, idx * turnTermMinutes) : null; baseTime && Number.isFinite(turnTermMinutes) ? addMinutes(baseTime, idx * turnTermMinutes) : null;
const timeLabel = turnDate const timeLabel = turnDate
? turnTermMinutes >= 5 ? turnTermMinutes >= 5
? formatServerDateTime(turnDate, { format: 'hourMinute' }) ? formatGameTime(turnDate, { format: 'hourMinute' })
: formatServerDateTime(turnDate, { format: 'minuteSecond' }) : formatGameTime(turnDate, { format: 'minuteSecond' })
: '--:--'; : '--:--';
const actionLabel = const actionLabel =
formatReservedCommandBrief('nation', turn.action, turn.args, commandTable.value) ?? formatReservedCommandBrief('nation', turn.action, turn.args, commandTable.value) ??
+2 -1
View File
@@ -10,7 +10,8 @@ import { sortGeneralsByTypeThenName } from '../utils/generalOrder';
import { useSessionStore } from '../stores/session'; import { useSessionStore } from '../stores/session';
import { cityLevelMap, formatOfficerLevelText, regionMap } from '../utils/nationFormat'; import { cityLevelMap, formatOfficerLevelText, regionMap } from '../utils/nationFormat';
import { getNpcColor } from '../utils/npcColor'; import { getNpcColor } from '../utils/npcColor';
import { formatSeoulDateTime } from '../utils/legacyDateTime'; import { useClockDisplay } from '../composables/useClockDisplay';
const { formatTime: formatSeoulDateTime } = useClockDisplay();
import { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../utils/generalIcon'; import { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../utils/generalIcon';
import { abilityLeadint, abilityLeadpow, abilityPowint, abilityRand, type GeneralStats } from '../utils/generalStats'; import { abilityLeadint, abilityLeadpow, abilityPowint, abilityRand, type GeneralStats } from '../utils/generalStats';
import { legacyLuminanceTextColor } from '../utils/legacyNationColor'; import { legacyLuminanceTextColor } from '../utils/legacyNationColor';
+2 -1
View File
@@ -3,7 +3,8 @@ import { JosaUtil } from '@sammo-ts/common/util/JosaUtil';
import { computed, onMounted, reactive, ref } from 'vue'; import { computed, onMounted, reactive, ref } from 'vue';
import { trpc } from '../utils/trpc'; import { trpc } from '../utils/trpc';
import { formatLog } from '../utils/formatLog'; import { formatLog } from '../utils/formatLog';
import { formatSeoulDateTime } from '../utils/legacyDateTime'; import { useClockDisplay } from '../composables/useClockDisplay';
const { formatTime: formatSeoulDateTime } = useClockDisplay();
import { isDefenceTrainPenaltyWaivedByScenarioEffect } from '@sammo-ts/logic/scenario/scenarioEffect.js'; import { isDefenceTrainPenaltyWaivedByScenarioEffect } from '@sammo-ts/logic/scenario/scenarioEffect.js';
import { useSessionStore } from '../stores/session'; import { useSessionStore } from '../stores/session';
import GeneralInformationPanel from '../components/main/GeneralInformationPanel.vue'; import GeneralInformationPanel from '../components/main/GeneralInformationPanel.vue';
@@ -1,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { useClockDisplay } from '../composables/useClockDisplay';
const { mode: clockDisplayMode } = useClockDisplay();
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'; import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { trpc } from '../utils/trpc'; import { trpc } from '../utils/trpc';
import { formatSeoulDateTime } from '../utils/legacyDateTime'; import { formatSeoulDateTime } from '../utils/legacyDateTime';
@@ -148,6 +150,18 @@ onBeforeUnmount(() => {
</div> </div>
</div> </div>
<div class="screen-mode-row">
<span
>가속 시간 표시 기준<br /><small
> 기기에 저장하며, 2배속 중에는 시계를 눌러 바꿀 있습니다.</small
></span
>
<div class="button-group" role="radiogroup" aria-label="가속 시간 표시 기준">
<label><input v-model="clockDisplayMode" type="radio" value="game" />게임 시간 기준</label>
<label><input v-model="clockDisplayMode" type="radio" value="real" />실제 시간 기준</label>
</div>
</div>
<div class="mobile-layout-setting-row"> <div class="mobile-layout-setting-row">
<span> <span>
모바일 메인 레이아웃<br /> 모바일 메인 레이아웃<br />
@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime'; import { useClockDisplay } from '../composables/useClockDisplay';
const { formatTime: formatGameTime } = useClockDisplay();
import { JosaUtil } from '@sammo-ts/common/util/JosaUtil'; import { JosaUtil } from '@sammo-ts/common/util/JosaUtil';
import { computed, onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
@@ -700,7 +701,7 @@ onMounted(async () => {
</template> </template>
</td> </td>
<td>{{ general.killTurn }}</td> <td>{{ general.killTurn }}</td>
<td>{{ formatServerDateTime(general.turnTime, { format: 'minuteSecond' }) }}</td> <td>{{ formatGameTime(general.turnTime, { format: 'minuteSecond' }) }}</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@@ -1,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { useClockDisplay } from '../composables/useClockDisplay';
const { formatTime: formatGameTime } = useClockDisplay();
import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime'; import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime';
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'; import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
@@ -495,7 +497,7 @@ const cellValue = (general: General, columnId: NationGeneralColumnId): CellValue
case 'reservedCommand': case 'reservedCommand':
return commandText(general, false); return commandText(general, false);
case 'turntime': case 'turntime':
return formatServerDateTime(details(general).turnTime, { format: 'minuteSecond', fallback: '?' }); return formatGameTime(details(general).turnTime, { format: 'minuteSecond', fallback: '?' });
case 'recent_war': case 'recent_war':
return formatServerDateTime(details(general).recentWar, { format: 'minuteSecond', fallback: '-' }); return formatServerDateTime(details(general).recentWar, { format: 'minuteSecond', fallback: '-' });
case 'years_1': case 'years_1':
@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime'; import { useClockDisplay } from '../composables/useClockDisplay';
const { formatTime: formatGameTime } = useClockDisplay();
import { computed, onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import { formatReservedCommandBrief } from '../components/command/reservedCommandBrief'; import { formatReservedCommandBrief } from '../components/command/reservedCommandBrief';
import type { CommandTable } from '../components/command/types'; import type { CommandTable } from '../components/command/types';
@@ -332,7 +333,7 @@ onMounted(load);
> >
</td> </td>
<td>{{ general.killTurn }}</td> <td>{{ general.killTurn }}</td>
<td>{{ formatServerDateTime(general.turnTime, { format: 'minuteSecond' }) }}</td> <td>{{ formatGameTime(general.turnTime, { format: 'minuteSecond' }) }}</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@@ -4,7 +4,8 @@ import { useRouter } from 'vue-router';
import { useGameFeedback } from '../composables/useGameFeedback'; import { useGameFeedback } from '../composables/useGameFeedback';
import { useSessionStore } from '../stores/session'; import { useSessionStore } from '../stores/session';
import { formatSeoulDateTime } from '../utils/legacyDateTime'; import { useClockDisplay } from '../composables/useClockDisplay';
const { formatTime: formatSeoulDateTime } = useClockDisplay();
import { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../utils/generalIcon'; import { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../utils/generalIcon';
import { legacyLuminanceTextColor } from '../utils/legacyNationColor'; import { legacyLuminanceTextColor } from '../utils/legacyNationColor';
import { trpc } from '../utils/trpc'; import { trpc } from '../utils/trpc';
@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime'; import { useClockDisplay } from '../composables/useClockDisplay';
const { formatTime: formatGameTime } = useClockDisplay();
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'; import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue';
import TournamentBracket from '../components/tournament/TournamentBracket.vue'; import TournamentBracket from '../components/tournament/TournamentBracket.vue';
@@ -55,7 +56,7 @@ const matchesAt = (stage: number) =>
.sort((a, b) => a.roundIndex - b.roundIndex); .sort((a, b) => a.roundIndex - b.roundIndex);
const nameOf = (id?: number) => (id ? (participantsById.value.get(id)?.name ?? `#${id}`) : '-'); const nameOf = (id?: number) => (id ? (participantsById.value.get(id)?.name ?? `#${id}`) : '-');
const openingTime = computed(() => const openingTime = computed(() =>
formatServerDateTime(snapshot.value?.state?.nextAt, { format: 'hourMinute', fallback: '--:--' }) formatGameTime(snapshot.value?.state?.nextAt, { format: 'hourMinute', fallback: '--:--' })
); );
const isParticipant = computed(() => const isParticipant = computed(() =>
(snapshot.value?.participants ?? []).some((participant) => participant.id === myGeneralId.value) (snapshot.value?.participants ?? []).some((participant) => participant.id === myGeneralId.value)
+3 -2
View File
@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime'; import { useClockDisplay } from '../composables/useClockDisplay';
const { formatTime: formatGameTime } = useClockDisplay();
import { computed, onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
@@ -170,7 +171,7 @@ const hideMemberPopup = () => {
const iconPath = (troop: Troop): string => resolveGeneralIconUrl(troop.leader ?? {}); const iconPath = (troop: Troop): string => resolveGeneralIconUrl(troop.leader ?? {});
const formatTurn = (turnTime: string | null): string => { const formatTurn = (turnTime: string | null): string => {
return formatServerDateTime(turnTime, { format: 'minuteSecond', fallback: '--:--' }); return formatGameTime(turnTime, { format: 'minuteSecond', fallback: '--:--' });
}; };
onMounted(() => { onMounted(() => {
@@ -4,6 +4,7 @@ import test from 'node:test';
import { import {
millisecondsUntilNextMinute, millisecondsUntilNextMinute,
projectServerClock, projectServerClock,
projectRecoveryTime,
sampleServerClock, sampleServerClock,
} from '../src/utils/serverClockProjection.ts'; } from '../src/utils/serverClockProjection.ts';
@@ -92,3 +93,59 @@ void test('waits then accelerates from a partial month and returns to normal wit
assert.equal(projectServerClock(sample, 2160000).rate, 1); assert.equal(projectServerClock(sample, 2160000).rate, 1);
assert.equal(projectServerClock(sample, 2160001).time.toISOString(), '2026-09-07T01:00:00.001Z'); assert.equal(projectServerClock(sample, 2160001).time.toISOString(), '2026-09-07T01:00:00.001Z');
}); });
void test('actual deadlines compress only the recovery window and preserve the original phase afterward', () => {
const sample = sampleServerClock(
{
serverTime: '2026-09-10T01:00:00Z',
serverWallTime: '2026-09-10T02:00:00Z',
clockRunning: true,
clockRecovery: { startsAt: '2026-09-10T02:00:00Z', endsAt: '2026-09-10T03:00:00Z' },
},
0
);
assert.ok(sample);
for (const [game, actual] of [
['01:20', '02:10'],
['02:20', '02:40'],
['03:20', '03:20'],
['01:00', '02:00'],
['03:00', '03:00'],
]) {
assert.equal(
projectRecoveryTime(sample, new Date(`2026-09-10T${game}:00Z`)).toISOString(),
`2026-09-10T${actual}:00.000Z`
);
}
// Recovery resampling must not move a deadline, even with a skewed browser clock.
const middle = sampleServerClock(
{
serverTime: '2026-09-10T02:00:00Z',
serverWallTime: '2026-09-10T02:30:00Z',
clockRecovery: { startsAt: '2026-09-10T02:00:00Z', endsAt: '2026-09-10T03:00:00Z' },
},
1234567
);
assert.equal(
projectRecoveryTime(middle, new Date('2026-09-10T02:20:00Z')).toISOString(),
'2026-09-10T02:40:00.000Z'
);
});
void test('actual deadline projection handles waiting, missing metadata, stopped clocks and historical dates', () => {
const input = {
serverTime: '2026-09-10T00:10:00Z',
serverWallTime: '2026-09-10T00:24:00Z',
clockRunning: false,
clockStartsAt: '2026-09-10T00:35:00Z',
clockRecovery: { startsAt: '2026-09-10T00:35:00Z', endsAt: '2026-09-10T01:00:00Z' },
};
const target = new Date('2026-09-10T00:20:00Z');
assert.equal(projectRecoveryTime(sampleServerClock(input, 0), target).toISOString(), '2026-09-10T00:40:00.000Z');
const history = new Date('2026-09-09T23:00:00Z');
assert.equal(projectRecoveryTime(sampleServerClock(input, 0), history), history);
assert.equal(projectRecoveryTime(sampleServerClock({ ...input, clockStartsAt: null }, 0), target), target);
assert.equal(projectRecoveryTime(sampleServerClock({ ...input, clockMode: 'manual' }, 0), target), target);
assert.equal(projectRecoveryTime(sampleServerClock({ ...input, clockRecovery: null }, 0), target), target);
assert.equal(projectRecoveryTime(null, target), target);
});