From 964c780c9b8fae9b83841dc3e221a10e7fdd6160 Mon Sep 17 00:00:00 2001 From: hided62 Date: Sat, 15 Aug 2026 18:17:45 +0000 Subject: [PATCH] =?UTF-8?q?fix(backend):=20=EC=83=9D=EC=84=B1=20=EC=9E=A5?= =?UTF-8?q?=EC=88=98=20=EC=B2=AB=20=ED=84=B4=EC=9D=84=20=EC=A0=91=EC=88=98?= =?UTF-8?q?=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');