From 964c780c9b8fae9b83841dc3e221a10e7fdd6160 Mon Sep 17 00:00:00 2001 From: hided62 Date: Sat, 15 Aug 2026 18:17:45 +0000 Subject: [PATCH 1/3] =?UTF-8?q?fix(backend):=20=EC=83=9D=EC=84=B1=20?= =?UTF-8?q?=EC=9E=A5=EC=88=98=20=EC=B2=AB=20=ED=84=B4=EC=9D=84=20=EC=A0=91?= =?UTF-8?q?=EC=88=98=20=EC=8B=9C=EA=B0=81=EC=97=90=20=EB=A7=9E=EC=B6=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit daemon 완료 시각이 뒤처져도 신규 장수 턴을 접수된 논리 게임 시각부터 한 턴 안에 배정한다. 로비 응답에는 보정 시계가 사용할 서버 게임 시각과 clock mode를 추가한다. --- app/game-api/src/router/lobby/index.ts | 4 +++ app/game-api/test/lobbyRouter.test.ts | 33 ++++++++++++++++++- .../src/turn/joinCreateGeneralService.ts | 13 +++++--- .../test/joinCreateGeneralService.test.ts | 30 +++++++++++++++++ 4 files changed, 75 insertions(+), 5 deletions(-) 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/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/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'); From 40e23b0c441c7ee5ee05a712ae3d79e3fa46ea75 Mon Sep 17 00:00:00 2001 From: hided62 Date: Sat, 15 Aug 2026 18:19:51 +0000 Subject: [PATCH 2/3] =?UTF-8?q?fix(game-ui):=20=EB=8A=A5=EB=A0=A5=EC=B9=98?= =?UTF-8?q?=20=EB=B2=84=ED=8A=BC=20=EB=88=8C=EB=A6=BC=20=EA=B9=8A=EC=9D=B4?= =?UTF-8?q?=EB=A5=BC=20Ref=EC=99=80=20=EB=A7=9E=EC=B6=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 장수 생성 빠른 설정 버튼에 공통 navigation 버튼의 어두운 옆면과 상태별 눌림 깊이를 적용한다. 실제 Chromium 검증에 border 색과 폭, 위치와 높이 회귀를 추가한다. --- app/game-frontend/e2e/joinLayout.spec.ts | 59 +++++++++++++++++++++--- app/game-frontend/src/views/JoinView.vue | 43 ++++++++++------- 2 files changed, 79 insertions(+), 23 deletions(-) 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/src/views/JoinView.vue b/app/game-frontend/src/views/JoinView.vue index 3b7968bf..3f65db42 100644 --- a/app/game-frontend/src/views/JoinView.vue +++ b/app/game-frontend/src/views/JoinView.vue @@ -726,10 +726,26 @@ onUnmounted(() => {
- - - - + + + +
@@ -1385,26 +1401,21 @@ onUnmounted(() => { .stat-actions button { flex: 1 1 140px; + height: 40px; min-height: 40px; - border: 1px solid #004f28; - border-radius: 3px; - background: #00582c; padding: 8px 14px; - color: #fff; font-size: 0.875rem; - font-weight: 700; line-height: 1.2; - cursor: pointer; } -.stat-actions button:hover { - border-color: #005f30; - background: #006d37; +.stat-actions button:not(:disabled):hover { + height: 39px; + min-height: 39px; } -.stat-actions button:active { - border-color: #003d1f; - background: #004523; +.stat-actions button:not(:disabled):active { + height: 38px; + min-height: 38px; } .stat-summary { From d0b9b8f211ac11513651a5a4bb67e9e06645e8e1 Mon Sep 17 00:00:00 2001 From: hided62 Date: Sat, 15 Aug 2026 18:17:59 +0000 Subject: [PATCH 3/3] =?UTF-8?q?fix(frontend):=20=EC=84=9C=EB=B2=84=20?= =?UTF-8?q?=EB=B3=B4=EC=A0=95=20=EC=8B=9C=EA=B0=81=EC=9D=84=20=EB=A1=9C?= =?UTF-8?q?=EC=BB=AC=20=EC=8B=9C=EA=B3=84=EB=A1=9C=20=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 명령 목록 중앙 시계는 서버 게임 시각과 클라이언트 시각 차이를 보정해 흐르게 한다. 장수 다음 턴과 예약 행 시각도 브라우저 로컬 시간대로 투영한다. --- app/game-frontend/e2e/mainNavigation.spec.ts | 39 +++++++++++---- .../src/components/main/CommandListPanel.vue | 48 ++++++++++++++++--- .../src/components/main/GeneralBasicCard.vue | 4 +- app/game-frontend/src/utils/legacyDateTime.ts | 8 ++++ app/game-frontend/src/views/MainView.vue | 4 ++ app/game-frontend/test/legacyDateTime.test.ts | 17 ++++++- 6 files changed, 100 insertions(+), 20 deletions(-) diff --git a/app/game-frontend/e2e/mainNavigation.spec.ts b/app/game-frontend/e2e/mainNavigation.spec.ts index 6100a9a6..c36fdc16 100644 --- a/app/game-frontend/e2e/mainNavigation.spec.ts +++ b/app/game-frontend/e2e/mainNavigation.spec.ts @@ -21,6 +21,8 @@ type NavigationFixture = { operations: string[]; generalName?: string; generalTurnTime?: string; + serverTime?: string; + clockMode?: 'realtime' | 'manual'; cityDefence?: number; cityState?: number; nationRate?: number; @@ -346,6 +348,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 ?? '', }); } @@ -766,7 +770,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, @@ -776,22 +780,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 턴'); @@ -836,9 +847,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` ), ]); @@ -846,9 +857,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, @@ -874,15 +885,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 @@