복구 2배속의 게임·실제 시간 표시 설정과 시계 빠른 전환 추가
This commit is contained in:
@@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -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));
|
||||||
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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: '--:--' })})`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) ??
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user