diff --git a/app/game-api/src/router/lobby/index.ts b/app/game-api/src/router/lobby/index.ts index a0817186..97e800c2 100644 --- a/app/game-api/src/router/lobby/index.ts +++ b/app/game-api/src/router/lobby/index.ts @@ -4,6 +4,7 @@ import { asRecord } from '@sammo-ts/common'; import { zWorldStateConfig, zWorldStateMeta } from '../../context.js'; import { isSelectionPoolWorld, resolveSelectionMaxGeneral } from '@sammo-ts/game-engine/turn/selectPoolService.js'; +import { loadCurrentGameTime } from '../../services/gameClock.js'; import { procedure, router } from '../../trpc.js'; export const lobbyRouter = router({ @@ -26,6 +27,7 @@ export const lobbyRouter = router({ const npcCnt = await ctx.db.general.count({ where: { npcState: { gte: 2 } } }); const nationCnt = await ctx.db.nation.count({ where: { level: { gt: 0 } } }); const scenarioTitle = asRecord(asRecord(rawWorldState.meta).scenarioMeta).title; + const gameTime = await loadCurrentGameTime(ctx.db); let myGeneral = null; if (ctx.auth?.user.id) { @@ -54,6 +56,8 @@ export const lobbyRouter = router({ starttime: worldState.meta.starttime ?? '', opentime: worldState.meta.opentime ?? '', turntime: worldState.meta.turntime ?? '', + serverTime: gameTime.now.toISOString(), + clockMode: gameTime.mode ?? 'realtime', otherTextInfo: worldState.meta.otherTextInfo ?? '', isUnited: worldState.meta.isunited ?? worldState.meta.isUnited ?? 0, selectionPoolEnabled: isSelectionPoolWorld(rawWorldState), diff --git a/app/game-api/test/commandTable.test.ts b/app/game-api/test/commandTable.test.ts index 9741acc8..456dc6f0 100644 --- a/app/game-api/test/commandTable.test.ts +++ b/app/game-api/test/commandTable.test.ts @@ -119,6 +119,7 @@ describe('buildTurnCommandTable', () => { 'che_단련', 'che_숙련전환', 'che_견문', + 'che_은퇴', 'che_장비매매', 'che_군량매매', 'che_내정특기초기화', @@ -135,9 +136,67 @@ describe('buildTurnCommandTable', () => { 'che_주민선정', ], 군사: ['che_징병', 'che_모병', 'che_훈련', 'che_사기진작', 'che_출병', 'che_집합', 'che_소집해제'], - 인사: ['che_이동', 'che_인재탐색', 'che_귀환', 'che_임관', 'che_랜덤임관'], + 인사: ['che_이동', 'che_강행', 'che_인재탐색', 'che_귀환', 'che_임관', 'che_랜덤임관'], 계략: ['che_화계'], - 국가: ['che_증여', 'che_헌납', 'che_물자조달', 'che_거병', 'che_건국', 'che_선양', 'che_해산'], + 국가: [ + 'che_증여', + 'che_헌납', + 'che_물자조달', + 'che_하야', + 'che_거병', + 'che_건국', + 'che_선양', + 'che_해산', + ], + }); + }); + + it('projects the Ref availability boundaries for force move, retirement, and resignation', async () => { + const buildTable = (general: GeneralRow, nation: NationRow | null = buildNation()) => + buildTurnCommandTable({ + worldState: buildWorldState(), + general, + city: buildCity(), + nation, + nationGenerals: null, + }); + const findCommand = (table: Awaited>, key: string) => + table.general.flatMap((group) => group.values).find((command) => command.key === key); + + const ordinary = await buildTable(buildGeneral()); + expect(findCommand(ordinary, 'che_강행')).toMatchObject({ + name: '강행', + reqArg: true, + possible: true, + inputFields: [{ key: 'destCityId', optionSource: 'cities' }], + }); + expect(findCommand(ordinary, 'che_은퇴')).toMatchObject({ + name: '은퇴', + possible: false, + status: 'blocked', + reason: '나이가 60세 이상이어야 합니다.', + }); + expect(findCommand(ordinary, 'che_하야')).toMatchObject({ + name: '하야', + possible: true, + status: 'available', + }); + + const oldEnough = await buildTable({ ...buildGeneral(), age: 60 } as GeneralRow); + expect(findCommand(oldEnough, 'che_은퇴')).toMatchObject({ possible: true, status: 'available' }); + + const ruler = await buildTable({ ...buildGeneral(), officerLevel: 12 } as GeneralRow); + expect(findCommand(ruler, 'che_하야')).toMatchObject({ + possible: false, + status: 'blocked', + reason: expect.stringContaining('군주'), + }); + + const neutral = await buildTable({ ...buildGeneral(), nationId: 0, officerLevel: 0 } as GeneralRow, null); + expect(findCommand(neutral, 'che_하야')).toMatchObject({ + possible: false, + status: 'blocked', + reason: '재야입니다.', }); }); diff --git a/app/game-api/test/lobbyRouter.test.ts b/app/game-api/test/lobbyRouter.test.ts index 3ba1b6f9..ecd9de5a 100644 --- a/app/game-api/test/lobbyRouter.test.ts +++ b/app/game-api/test/lobbyRouter.test.ts @@ -3,7 +3,15 @@ import { describe, expect, it, vi } from 'vitest'; import type { DatabaseClient, GameApiContext } from '../src/context.js'; import { appRouter } from '../src/router.js'; -const buildContext = (meta: Record): GameApiContext => +const buildContext = ( + meta: Record, + clock: { + baseTime?: Date; + tick?: bigint; + mode?: string; + wallAnchor?: Date; + } = {} +): GameApiContext => ({ auth: null, db: { @@ -16,6 +24,10 @@ const buildContext = (meta: Record): GameApiContext => tickSeconds: 3_600, config: {}, meta, + clockBaseTime: clock.baseTime ?? null, + clockTick: clock.tick ?? null, + clockMode: clock.mode ?? 'realtime', + clockWallAnchor: clock.wallAnchor ?? null, updatedAt: new Date('2026-07-31T00:00:00.000Z'), })), }, @@ -36,4 +48,23 @@ describe('lobby season state', () => { expect(result.isUnited).toBe(isunited); }); + + it('returns the projected server game time and whether the clock is running', async () => { + const result = await appRouter + .createCaller( + buildContext( + {}, + { + baseTime: new Date('2026-08-15T00:00:00.000Z'), + tick: 72_000_000n, + mode: 'manual', + wallAnchor: new Date('2026-08-15T17:00:00.000Z'), + } + ) + ) + .lobby.info(); + + expect(result.serverTime).toBe('2026-08-15T02:00:00.000Z'); + expect(result.clockMode).toBe('manual'); + }); }); diff --git a/app/game-engine/src/turn/joinCreateGeneralService.ts b/app/game-engine/src/turn/joinCreateGeneralService.ts index 5ce18abb..c5b52545 100644 --- a/app/game-engine/src/turn/joinCreateGeneralService.ts +++ b/app/game-engine/src/turn/joinCreateGeneralService.ts @@ -330,8 +330,8 @@ export const cutJoinTurnTime = (value: Date, tickSeconds: number): Date => { return new Date(baseTime + alignedSeconds * 1000); }; -const resolveTurnTime = ( - rng: RandUtil, +export const resolveJoinTurnTime = ( + rng: Pick, worldState: WorldStateRow, acceptedAt: Date, runtimeTurnTime: Date, @@ -348,7 +348,12 @@ const resolveTurnTime = ( offsetSeconds = inheritTurntimeZone * legacyTurnTermMinutes + rng.nextRangeInt(0, legacyTurnTermMinutes - 1); offsetMicros = rng.nextRangeInt(0, 999_999); } else { - turnTimeBase = base; + // Ref normally uses game_env.turntime as a near-current cursor. Core's + // durable daemon can legitimately be catching up from an older cursor, + // so scheduling from runtimeTurnTime may put a newly created general + // hours behind the game clock. The accepted game time is the equivalent + // current-time boundary for a new general. + turnTimeBase = acceptedAt; offsetSeconds = rng.nextRangeInt(0, tickSeconds - 1); offsetMicros = rng.nextRangeInt(0, 999_999); } @@ -662,7 +667,7 @@ export const createGeneralFromJoin = async (options: { } const experience = await resolveCatchupExperience(db, relativeYear); - const turnTime = resolveTurnTime( + const turnTime = resolveJoinTurnTime( rng, worldState, acceptedAt, diff --git a/app/game-engine/test/defaultCommandProfileAiCoverage.test.ts b/app/game-engine/test/defaultCommandProfileAiCoverage.test.ts index c8c8cc06..6482c865 100644 --- a/app/game-engine/test/defaultCommandProfileAiCoverage.test.ts +++ b/app/game-engine/test/defaultCommandProfileAiCoverage.test.ts @@ -18,13 +18,16 @@ const GENERAL_AI_ACTIONS = [ const NATION_AI_ACTIONS = ['che_몰수', 'che_발령', 'che_선전포고', 'che_천도', 'che_포상'] as const; const GENERAL_REF_EDITOR_ACTIONS = [ + 'che_은퇴', 'che_임관', 'che_랜덤임관', + 'che_강행', 'che_징병', 'che_출병', 'che_농지개간', 'che_화계', 'che_증여', + 'che_하야', 'che_장비매매', ] as const; const NATION_REF_EDITOR_ACTIONS = ['che_포상', 'che_발령', 'che_증축', 'che_필사즉생'] as const; diff --git a/app/game-engine/test/joinCreateGeneralService.test.ts b/app/game-engine/test/joinCreateGeneralService.test.ts index 10c08920..18d4ff3e 100644 --- a/app/game-engine/test/joinCreateGeneralService.test.ts +++ b/app/game-engine/test/joinCreateGeneralService.test.ts @@ -4,6 +4,7 @@ import { buildJoinCreateGeneralSeed, cutJoinTurnTime, JOIN_WELCOME_MESSAGE, + resolveJoinTurnTime, } from '../src/turn/joinCreateGeneralService.js'; describe('generic join legacy time contracts', () => { @@ -19,6 +20,35 @@ describe('generic join legacy time contracts', () => { ); }); + it('schedules a new general within one turn of the accepted game time even when the daemon cursor is stale', () => { + const calls: Array<[number, number]> = []; + const values = [59, 250_000]; + const rng = { + nextRangeInt(min: number, max: number) { + calls.push([min, max]); + return values.shift() ?? min; + }, + }; + const acceptedAt = new Date('2026-08-15T17:57:05.837Z'); + const staleRuntimeTurnTime = new Date('2026-08-15T07:10:00.000Z'); + + const turnTime = resolveJoinTurnTime( + rng, + { tickSeconds: 120 } as Parameters[1], + acceptedAt, + staleRuntimeTurnTime, + undefined + ); + + expect(turnTime.toISOString()).toBe('2026-08-15T17:58:05.087Z'); + expect(turnTime.getTime()).toBeGreaterThan(acceptedAt.getTime()); + expect(turnTime.getTime()).toBeLessThanOrEqual(acceptedAt.getTime() + 120_000); + expect(calls).toEqual([ + [0, 119], + [0, 999_999], + ]); + }); + it('uses the HiDCHe product name without the legacy PHP runtime label', () => { expect(JOIN_WELCOME_MESSAGE).toBe('삼국지 모의전투 HiDCHe의 세계에 오신 것을 환영합니다 ^o^'); expect(JOIN_WELCOME_MESSAGE).not.toContain('PHP'); diff --git a/app/game-frontend/e2e/commandArguments.spec.ts b/app/game-frontend/e2e/commandArguments.spec.ts index 2991c6ac..9728e31d 100644 --- a/app/game-frontend/e2e/commandArguments.spec.ts +++ b/app/game-frontend/e2e/commandArguments.spec.ts @@ -294,7 +294,7 @@ const chiefCenter = { })), }; -const install = async (page: Page, rejectGeneral = false) => { +const install = async (page: Page, rejectGeneral = false, commandTableResponse: unknown = commandTable) => { const requests: unknown[] = []; const generalTurns = turns(30); const nationTurns = turns(12); @@ -344,7 +344,7 @@ const install = async (page: Page, rejectGeneral = false) => { ? { kind: 'snapshot', revision: 'BBBBBBBBBBBBBBBBBBBBBB', - data: commandTable, + data: commandTableResponse, } : { kind: 'unchanged', revision: 'BBBBBBBBBBBBBBBBBBBBBB' }, boardAccess: initial @@ -400,7 +400,7 @@ const install = async (page: Page, rejectGeneral = false) => { myCity: 1, myNation: 1, }); - if (name === 'turns.getCommandTable') return response(commandTable); + if (name === 'turns.getCommandTable') return response(commandTableResponse); if (name === 'nation.getChiefCenter') return response(chiefCenter); if (name === 'turns.reserved.getGeneral') return response({ turns: generalTurns, revision: generalRevision }); @@ -461,6 +461,117 @@ const install = async (page: Page, rejectGeneral = false) => { return requests; }; +test('reserves force move, retirement, and resignation from the user command picker', async ({ page }) => { + const specialCommandTable = { + general: [ + { + category: '개인', + values: [ + { + key: 'che_은퇴', + name: '은퇴', + reqArg: false, + possible: false, + status: 'blocked', + reason: '나이가 60세 이상이어야 합니다.', + inputFields: [], + }, + ], + }, + { + category: '인사', + values: [ + { + key: 'che_강행', + name: '강행', + reqArg: true, + possible: true, + status: 'available', + inputFields: [ + { + key: 'destCityId', + label: '대상 도시', + kind: 'select', + required: true, + optionSource: 'cities', + }, + ], + }, + ], + }, + { + category: '국가', + values: [ + { + key: 'che_하야', + name: '하야', + reqArg: false, + possible: true, + status: 'available', + inputFields: [], + }, + ], + }, + ], + nation: [], + inputOptions, + }; + const requests = await install(page, false, specialCommandTable); + await page.setViewportSize({ width: 1200, height: 900 }); + await page.goto('/'); + + const editor = page.locator('[data-command-scope="general"]'); + + await editor.getByRole('button', { name: '1턴 명령 입력', exact: true }).click(); + let picker = page.getByTestId('command-picker'); + const retirement = picker.getByRole('button', { name: '은퇴', exact: true }); + await expect(retirement).toHaveClass(/blocked/); + await expect(retirement).toHaveAttribute('title', '나이가 60세 이상이어야 합니다.'); + await retirement.hover(); + await retirement.focus(); + await expect(retirement).toBeFocused(); + await picker.screenshot({ path: test.info().outputPath('special-user-commands-desktop-1200.png') }); + await retirement.click(); + await expect(editor.locator('.action-column > div').nth(0)).toHaveText('은퇴'); + + await editor.getByRole('button', { name: '2턴 명령 입력', exact: true }).click(); + picker = page.getByTestId('command-picker'); + await picker.getByRole('button', { name: '국가', exact: true }).click(); + await picker.getByRole('button', { name: '하야', exact: true }).click(); + await expect(editor.locator('.action-column > div').nth(1)).toHaveText('하야'); + + await editor.getByRole('button', { name: '3턴 명령 입력', exact: true }).click(); + picker = page.getByTestId('command-picker'); + await picker.getByRole('button', { name: '인사', exact: true }).click(); + await picker.getByRole('button', { name: '강행', exact: true }).click(); + const forceMoveForm = picker.getByTestId('command-argument-form'); + await expect(forceMoveForm.getByTestId('command-argument-guidance')).toContainText('선택한 도시로 강행합니다.'); + await forceMoveForm.locator('select').selectOption('2'); + await picker.getByRole('button', { name: '입력', exact: true }).click(); + await expect(editor.locator('.action-column > div').nth(2)).toHaveText('강행'); + + const serialized = JSON.stringify(requests); + expect(serialized).toContain('"action":"che_은퇴","args":{}'); + expect(serialized).toContain('"action":"che_하야","args":{}'); + expect(serialized).toContain('"action":"che_강행","args":{"destCityId":2}'); + + await page.setViewportSize({ width: 500, height: 900 }); + await editor.getByRole('button', { name: '4턴 명령 입력', exact: true }).click(); + picker = page.getByTestId('command-picker'); + await expect(picker.locator('.category-btn')).toHaveText(['개인', '인사', '국가']); + const mobileGeometry = await picker.evaluate((element) => ({ + width: element.getBoundingClientRect().width, + horizontalOverflow: element.scrollWidth - element.clientWidth, + categoryColumns: getComputedStyle(element.querySelector('.category-list')!).gridTemplateColumns, + })); + expect(mobileGeometry.width).toBeLessThanOrEqual(500); + expect(mobileGeometry.horizontalOverflow).toBeLessThanOrEqual(0); + expect(mobileGeometry.categoryColumns.split(' ')).toHaveLength(3); + await picker.getByRole('button', { name: '개인', exact: true }).click(); + await expect(picker.getByRole('button', { name: '은퇴', exact: true })).toBeVisible(); + await picker.screenshot({ path: test.info().outputPath('special-user-commands-mobile-500.png') }); +}); + test('enters general and nation command arguments and sends exact values', async ({ page }) => { const requests = await install(page); await page.setViewportSize({ width: 1200, height: 900 }); diff --git a/app/game-frontend/e2e/inGameMenus.spec.ts b/app/game-frontend/e2e/inGameMenus.spec.ts index 1a241beb..05503f87 100644 --- a/app/game-frontend/e2e/inGameMenus.spec.ts +++ b/app/game-frontend/e2e/inGameMenus.spec.ts @@ -656,17 +656,32 @@ test('메인 카드의 국가·수도·관직·계급·병종은 Ref 출력명 await expect(page.locator('.main-page')).not.toContainText('che_'); }); -test('메인 개인 기록의 전투 결과는 월 표제와 시각을 한 줄에 표시한다', async ({ page }) => { +test('메인 장수 동향과 개인 전투 기록은 모두 21px 행 간격을 유지한다', async ({ page }) => { const state: FixtureState = { permission: 'head', myset: 3, settingMutations: [], accessPages: [], recentRecords: { - global: [], - general: [ + global: [ + { + id: 18611, + text: + '●9월:Administrator가 낙양으로 ' + + '진격합니다.(전투시드: 0123456789abcdef)', + }, + { + id: 18610, + text: '●9월:Administrator가 임관했습니다.', + }, { id: 18609, + text: '●9월:뇌동의 기병이 퇴각했습니다.', + }, + ], + general: [ + { + id: 18608, text: '◆186년 9월:
' + '귀병 ' + @@ -686,6 +701,31 @@ test('메인 개인 기록의 전투 결과는 월 표제와 시각을 한 줄 await page.setViewportSize({ width: 1200, height: 900 }); await page.goto(''); + const inspectGlobalRhythm = async (selector: string) => { + const lines = page.locator(selector); + await expect(lines).toHaveCount(3); + return lines.evaluateAll((elements) => + elements.map((element) => { + const rect = element.getBoundingClientRect(); + return { + top: rect.top, + height: rect.height, + lineHeight: getComputedStyle(element).lineHeight, + }; + }) + ); + }; + const assertUniformGlobalRhythm = (geometry: Awaited>) => { + expect(geometry.map((line) => line.height)).toEqual([21, 21, 21]); + expect(geometry.map((line) => line.lineHeight)).toEqual(['21px', '21px', '21px']); + expect(geometry[1]!.top - geometry[0]!.top).toBe(21); + expect(geometry[2]!.top - geometry[1]!.top).toBe(21); + }; + + const desktopGlobalGeometry = await inspectGlobalRhythm('.record-zone [data-record-bucket="global"] .record-line'); + assertUniformGlobalRhythm(desktopGlobalGeometry); + await persistParityArtifact(page, 'core-main-trend-log-rhythm-desktop', desktopGlobalGeometry); + const expectedText = '◆186년 9월:귀병 【Administrator】 0(-2209) ← 1361(-5539) 기병 【ⓝ뇌동】 12:54'; const inspect = async (line: Locator) => { await expect(line).toContainText(expectedText); @@ -723,6 +763,11 @@ test('메인 개인 기록의 전투 결과는 월 표제와 시각을 한 줄 page.locator('.record-zone-mobile [data-record-bucket="general"] .record-line').first() ); assertSingleLine(mobileGeometry); + const mobileGlobalGeometry = await inspectGlobalRhythm( + '.record-zone-mobile [data-record-bucket="global"] .record-line' + ); + assertUniformGlobalRhythm(mobileGlobalGeometry); + await persistParityArtifact(page, 'core-main-trend-log-rhythm-mobile', mobileGlobalGeometry); await persistParityArtifact(page, 'core-main-personal-battle-log-inline-mobile', mobileGeometry); }); diff --git a/app/game-frontend/e2e/joinLayout.spec.ts b/app/game-frontend/e2e/joinLayout.spec.ts index 337fe9b9..0df9167b 100644 --- a/app/game-frontend/e2e/joinLayout.spec.ts +++ b/app/game-frontend/e2e/joinLayout.spec.ts @@ -179,26 +179,54 @@ test('prioritizes core general fields and keeps context and inheritance progress const rect = element.getBoundingClientRect(); const style = getComputedStyle(element); return { + top: rect.top, height: rect.height, backgroundColor: style.backgroundColor, color: style.color, + borderTopWidth: style.borderTopWidth, + borderRightWidth: style.borderRightWidth, + borderBottomWidth: style.borderBottomWidth, + borderBottomColor: style.borderBottomColor, + marginTop: style.marginTop, fontSize: style.fontSize, fontWeight: style.fontWeight, cursor: style.cursor, }; }); - expect(defaultButtonStyle).toEqual({ + expect(defaultButtonStyle).toMatchObject({ height: 40, backgroundColor: 'rgb(0, 88, 44)', color: 'rgb(255, 255, 255)', + borderTopWidth: '0px', + borderRightWidth: '1px', + borderBottomWidth: '4px', + borderBottomColor: 'rgb(0, 79, 40)', + marginTop: '0px', fontSize: '14px', fontWeight: '700', cursor: 'pointer', }); await randomButton.hover(); - await expect - .poll(() => randomButton.evaluate((element) => getComputedStyle(element).backgroundColor)) - .toBe('rgb(0, 109, 55)'); + const hoverButtonStyle = await randomButton.evaluate((element) => { + const rect = element.getBoundingClientRect(); + const style = getComputedStyle(element); + return { + top: rect.top, + height: rect.height, + backgroundColor: style.backgroundColor, + borderBottomWidth: style.borderBottomWidth, + borderBottomColor: style.borderBottomColor, + marginTop: style.marginTop, + }; + }); + expect(hoverButtonStyle).toMatchObject({ + top: defaultButtonStyle.top + 1, + height: 39, + backgroundColor: 'rgb(0, 88, 44)', + borderBottomWidth: '3px', + borderBottomColor: 'rgb(0, 79, 40)', + marginTop: '1px', + }); await page.screenshot({ path: testInfo.outputPath('join-stat-actions-hover-desktop.png'), fullPage: true }); await randomButton.focus(); await expect(randomButton).toBeFocused(); @@ -210,9 +238,26 @@ test('prioritizes core general fields and keeps context and inheritance progress randomButtonBox!.y + randomButtonBox!.height / 2 ); await page.mouse.down(); - await expect - .poll(() => randomButton.evaluate((element) => getComputedStyle(element).backgroundColor)) - .toBe('rgb(0, 69, 35)'); + const activeButtonStyle = await randomButton.evaluate((element) => { + const rect = element.getBoundingClientRect(); + const style = getComputedStyle(element); + return { + top: rect.top, + height: rect.height, + backgroundColor: style.backgroundColor, + borderBottomWidth: style.borderBottomWidth, + borderBottomColor: style.borderBottomColor, + marginTop: style.marginTop, + }; + }); + expect(activeButtonStyle).toMatchObject({ + top: defaultButtonStyle.top + 2, + height: 38, + backgroundColor: 'rgb(0, 88, 44)', + borderBottomWidth: '2px', + borderBottomColor: 'rgb(0, 79, 40)', + marginTop: '2px', + }); await page.screenshot({ path: testInfo.outputPath('join-stat-actions-active-desktop.png'), fullPage: true }); await page.mouse.up(); const setRandomValues = async (values: number[]) => { diff --git a/app/game-frontend/e2e/mainNavigation.spec.ts b/app/game-frontend/e2e/mainNavigation.spec.ts index 6751dba8..ff5750a3 100644 --- a/app/game-frontend/e2e/mainNavigation.spec.ts +++ b/app/game-frontend/e2e/mainNavigation.spec.ts @@ -28,6 +28,8 @@ type NavigationFixture = { operations: string[]; generalName?: string; generalTurnTime?: string; + serverTime?: string; + clockMode?: 'realtime' | 'manual'; cityDefence?: number; cityState?: number; nationRate?: number; @@ -371,6 +373,8 @@ const installFixture = async (page: Page, state: NavigationFixture) => { year: state.currentYear ?? 185, month: state.currentMonth ?? 1, turnTerm: 10, + serverTime: state.serverTime ?? '2026-08-13T00:00:00.000Z', + clockMode: state.clockMode ?? 'realtime', scenarioTitle: state.scenarioTitle ?? '', }); } @@ -803,7 +807,7 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro await persistArtifact(page, `${basePath.slice(1)}-desktop-1200`); }); -test('main general card and command clock render the next turn with second precision', async ({ page }) => { +test('main general card uses local turn time and command clock tracks corrected server time', async ({ page }) => { const state: NavigationFixture = { officerLevel: 0, permission: 0, @@ -813,22 +817,29 @@ test('main general card and command clock render the next turn with second preci generalMeCalls: 0, operations: [], generalName: 'Administrator', - generalTurnTime: '2026-08-13T00:07:06.713Z', + generalTurnTime: '2026-08-13T00:09:10.713Z', + serverTime: '2026-08-13T00:07:06.250Z', + clockMode: 'realtime', currentYear: 179, currentMonth: 8, }; await installFixture(page, state); + await page.clock.install({ time: new Date('2026-08-13T00:00:00.000Z') }); + const cdp = await page.context().newCDPSession(page); + await cdp.send('Emulation.setTimezoneOverride', { timezoneId: 'Asia/Seoul' }); await page.setViewportSize({ width: 1200, height: 900 }); await waitForMain(page); const title = page.locator('[data-main-target="general"] .general-title').first(); await expect(title).toContainText('Administrator'); await expect(title).toContainText('용장'); - await expect(title).toContainText('09:07:06'); - await expect(title).not.toContainText('00:07'); + await expect(title).toContainText('09:09:10'); + await expect(title).not.toContainText('00:09'); const commandClock = page.locator('[data-main-target="commands"] [data-command-current-time]').first(); await expect(commandClock).toHaveText('09:07:06'); await expect(commandClock).not.toHaveText('00:07'); + await page.clock.runFor(1_000); + await expect(commandClock).toHaveText('09:07:07'); const generalCard = page.locator('[data-main-target="general"] [data-general-basic-card]').first(); await expect(generalCard).toContainText('수비 함(훈사80)'); await expect(generalCard).toContainText('5 턴'); @@ -873,9 +884,9 @@ test('main general card and command clock render the next turn with second preci const target = resolve(artifactRoot); await mkdir(target, { recursive: true }); await Promise.all([ - page.screenshot({ path: resolve(target, 'main-turn-time-seoul-desktop-1200.png'), fullPage: true }), + page.screenshot({ path: resolve(target, 'main-turn-time-local-desktop-1200.png'), fullPage: true }), writeFile( - resolve(target, 'main-turn-time-seoul-desktop-1200.json'), + resolve(target, 'main-turn-time-local-desktop-1200.json'), `${JSON.stringify({ title: desktopGeometry, commandClock: desktopClockGeometry }, null, 2)}\n` ), ]); @@ -883,9 +894,9 @@ test('main general card and command clock render the next turn with second preci await page.setViewportSize({ width: 500, height: 900 }); const mobileTitle = page.locator('[data-main-target="general"] .general-title').first(); - await expect(mobileTitle).toContainText('09:07:06'); + await expect(mobileTitle).toContainText('09:09:10'); const mobileCommandClock = page.locator('[data-main-target="commands"] [data-command-current-time]').first(); - await expect(mobileCommandClock).toHaveText('09:07:06'); + await expect(mobileCommandClock).toHaveText('09:07:07'); const mobileGeometry = { title: await mobileTitle.evaluate((element) => ({ width: element.getBoundingClientRect().width, @@ -911,15 +922,23 @@ test('main general card and command clock render the next turn with second preci if (artifactRoot) { await Promise.all([ page.screenshot({ - path: resolve(artifactRoot, 'main-turn-time-seoul-mobile-500.png'), + path: resolve(artifactRoot, 'main-turn-time-local-mobile-500.png'), fullPage: true, }), writeFile( - resolve(artifactRoot, 'main-turn-time-seoul-mobile-500.json'), + resolve(artifactRoot, 'main-turn-time-local-mobile-500.json'), `${JSON.stringify(mobileGeometry, null, 2)}\n` ), ]); } + + state.clockMode = 'manual'; + state.serverTime = '2026-08-13T00:08:30.000Z'; + await page.reload(); + const frozenClock = page.locator('[data-main-target="commands"] [data-command-current-time]').first(); + await expect(frozenClock).toHaveText('09:08:30'); + await page.clock.runFor(2_000); + await expect(frozenClock).toHaveText('09:08:30'); }); test('pure NPC message senders are not rendered as reply targets', async ({ page }) => { diff --git a/app/game-frontend/src/components/main/CommandListPanel.vue b/app/game-frontend/src/components/main/CommandListPanel.vue index 86ae7745..0a2d2124 100644 --- a/app/game-frontend/src/components/main/CommandListPanel.vue +++ b/app/game-frontend/src/components/main/CommandListPanel.vue @@ -1,8 +1,8 @@