From 15f6acb4a23235ee2f80821b9cf2931dae13e6d3 Mon Sep 17 00:00:00 2001 From: hided62 Date: Thu, 20 Aug 2026 16:06:07 +0000 Subject: [PATCH] =?UTF-8?q?fix:=20=EA=B0=9C=EC=9D=B8=20=EA=B8=B0=EB=A1=9D?= =?UTF-8?q?=EC=97=90=20=EC=9E=A5=EC=88=98=EB=B3=84=20=EC=8B=A4=ED=96=89=20?= =?UTF-8?q?=EC=8B=9C=EA=B0=81=EC=9D=84=20=EB=B3=B4=EC=A1=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 로그 발생 시각과 트랜잭션 flush 시각을 분리하고, 정규 및 즉시 장수 행동 로그에는 실행 전 장수 턴 시각을 기록한다. 기존 로그 행은 변경하지 않는다. --- app/game-engine/src/turn/inMemoryWorld.ts | 13 +++++++++++-- .../src/turn/reservedTurnHandler.ts | 15 +++++++++------ ...sScoreResetPersistence.integration.test.ts | 18 +++++++++++++++++- .../test/generalTurnLifecycle.test.ts | 19 +++++++++++++++++++ packages/logic/src/logging/entries.ts | 5 +++-- packages/logic/src/logging/types.ts | 2 ++ packages/logic/test/loggingEntries.test.ts | 18 ++++++++++++++++++ 7 files changed, 79 insertions(+), 11 deletions(-) diff --git a/app/game-engine/src/turn/inMemoryWorld.ts b/app/game-engine/src/turn/inMemoryWorld.ts index 75a443f3..1fa3aeba 100644 --- a/app/game-engine/src/turn/inMemoryWorld.ts +++ b/app/game-engine/src/turn/inMemoryWorld.ts @@ -814,7 +814,11 @@ export class InMemoryTurnWorld { }; } - pushLog(entry: LogEntryDraft): void { + pushLog(entry: LogEntryDraft, occurredAt?: Date): void { + if (occurredAt && !entry.occurredAt) { + this.logs.push({ ...entry, occurredAt: new Date(occurredAt.getTime()) }); + return; + } this.logs.push(entry); } @@ -1382,7 +1386,12 @@ export class InMemoryTurnWorld { this.dirtyNationIds.add(result.nation.id); } if (result.logs && result.logs.length > 0) { - this.logs.push(...result.logs); + // Ref command logs use the executing general's pre-advance turntime. + // Preserve that per-entry occurrence time instead of replacing every + // log in the transaction with the shared completion cursor at flush. + for (const log of result.logs) { + this.pushLog(log, currentGeneral.turnTime); + } } if (result.messages && result.messages.length > 0) { this.messages.push(...result.messages); diff --git a/app/game-engine/src/turn/reservedTurnHandler.ts b/app/game-engine/src/turn/reservedTurnHandler.ts index aca2f06b..96486190 100644 --- a/app/game-engine/src/turn/reservedTurnHandler.ts +++ b/app/game-engine/src/turn/reservedTurnHandler.ts @@ -2245,10 +2245,13 @@ export const createImmediateGeneralActionExecutor = async (options: { definition.formatConstraintFailure?.(reason, constraintCtx, args, view) ?? `${reason} ${definition.name} 실패.`; if (input.actionKey === 'che_접경귀환' || input.actionKey === 'che_등용수락') { - options.world.pushLog({ - ...createActionLog(failureText), - generalId: general.id, - }); + options.world.pushLog( + { + ...createActionLog(failureText), + generalId: general.id, + }, + general.turnTime + ); } return { ok: false, reason: failureText }; } @@ -2325,7 +2328,7 @@ export const createImmediateGeneralActionExecutor = async (options: { if (input.actionKey === 'che_접경귀환' && (resolution.general as TurnGeneral).cityId === general.cityId) { for (const log of resolution.logs) { - options.world.pushLog(log); + options.world.pushLog(log, general.turnTime); } return { ok: false, reason: '가까운 아국 도시가 없습니다.' }; } @@ -2409,7 +2412,7 @@ export const createImmediateGeneralActionExecutor = async (options: { options.world.removeTroop(troopId); } for (const log of [...resolution.logs, ...progressionLogs]) { - options.world.pushLog(log); + options.world.pushLog(log, general.turnTime); } options.world.updateGeneral(input.generalId, nextGeneral); return { ok: true }; diff --git a/app/game-engine/test/generalAccessScoreResetPersistence.integration.test.ts b/app/game-engine/test/generalAccessScoreResetPersistence.integration.test.ts index 43eed0c0..73abf04c 100644 --- a/app/game-engine/test/generalAccessScoreResetPersistence.integration.test.ts +++ b/app/game-engine/test/generalAccessScoreResetPersistence.integration.test.ts @@ -1,5 +1,6 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra'; +import { LogCategory, LogScope } from '@sammo-ts/logic'; import { createDatabaseTurnHooks } from '../src/turn/databaseHooks.js'; import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; @@ -15,6 +16,7 @@ integration('general access score reset persistence', () => { let closeDb: (() => Promise) | undefined; const cleanup = async () => { + await db.logEntry.deleteMany({ where: { generalId } }); await db.generalAccessLog.deleteMany({ where: { generalId } }); await db.general.deleteMany({ where: { id: generalId } }); await db.worldState.deleteMany({ where: { scenarioCode } }); @@ -33,8 +35,9 @@ integration('general access score reset persistence', () => { await closeDb?.(); }); - it('commits the own-turn reset marker in the same world flush', async () => { + it('commits the own-turn reset marker and per-entry log occurrence time in the same world flush', async () => { const turnTime = new Date('2026-08-15T00:10:00.000Z'); + const occurredAt = new Date('2026-08-15T00:07:43.000Z'); await db.general.create({ data: { id: generalId, @@ -95,6 +98,13 @@ integration('general access score reset persistence', () => { { schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] } } ); world.markGeneralAccessScoreReset(generalId); + world.pushLog({ + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + text: '●1월:아무것도 실행하지 않았습니다.', + generalId, + occurredAt, + }); const hooks = await createDatabaseTurnHooks(databaseUrl!, world); try { @@ -113,6 +123,12 @@ integration('general access score reset persistence', () => { refreshScoreTotal: 999, }); expect(world.peekDirtyState().accessScoreResetGeneralIds).toEqual([]); + expect( + await db.logEntry.findFirstOrThrow({ + where: { generalId, category: LogCategory.ACTION }, + select: { createdAt: true }, + }) + ).toEqual({ createdAt: occurredAt }); } finally { await hooks.close(); } diff --git a/app/game-engine/test/generalTurnLifecycle.test.ts b/app/game-engine/test/generalTurnLifecycle.test.ts index 15b4389c..05f6b330 100644 --- a/app/game-engine/test/generalTurnLifecycle.test.ts +++ b/app/game-engine/test/generalTurnLifecycle.test.ts @@ -159,6 +159,25 @@ const makeState = (meta: Record = {}): TurnWorldState => ({ }); describe('legacy general turn lifecycle', () => { + it('timestamps action logs with the executing general turn instead of the shared flush cursor', async () => { + const flushCursor = new Date('0200-01-01T00:35:00.000Z'); + const generalTurnTime = new Date('0200-01-01T00:37:43.000Z'); + const harness = await createTurnTestHarness({ + snapshot: makeSnapshot([makeGeneral({ turnTime: generalTurnTime })]), + state: { ...makeState(), lastTurnTime: flushCursor }, + schedule, + map, + }); + harness.reservedTurnStore.getGeneralTurns(1)[0] = { action: '휴식', args: {} }; + + await harness.runOneTick(); + + const actionLog = harness.world + .peekDirtyState() + .logs.find((log) => log.text.includes('아무것도 실행하지 않았습니다.')); + expect(actionLog?.occurredAt).toEqual(generalTurnTime); + }); + it('emits legacy plain logs when command gains cross experience and dedication levels', async () => { const harness = await createTurnTestHarness({ snapshot: makeSnapshot([ diff --git a/packages/logic/src/logging/entries.ts b/packages/logic/src/logging/entries.ts index 5747794b..4598da04 100644 --- a/packages/logic/src/logging/entries.ts +++ b/packages/logic/src/logging/entries.ts @@ -47,8 +47,9 @@ export const finalizeLogEntry = (entry: LogEntryDraft, context: LogContext): Log if (entry.meta !== undefined) { record.meta = entry.meta; } - if (context.at !== undefined) { - record.createdAt = context.at; + const createdAt = entry.occurredAt ?? context.at; + if (createdAt !== undefined) { + record.createdAt = createdAt; } return record; diff --git a/packages/logic/src/logging/types.ts b/packages/logic/src/logging/types.ts index 1d7a2cc3..fbd3afa1 100644 --- a/packages/logic/src/logging/types.ts +++ b/packages/logic/src/logging/types.ts @@ -31,6 +31,8 @@ export interface LogEntryDraft { /** 월 경계 전 action처럼 flush 시점과 다른 달에 귀속되는 로그의 명시적 날짜. */ year?: number; month?: number; + /** 로그를 만든 논리 게임 시각. 생략하면 flush context의 시각을 사용한다. */ + occurredAt?: Date; } export interface LogEntryRecord { diff --git a/packages/logic/test/loggingEntries.test.ts b/packages/logic/test/loggingEntries.test.ts index f7061410..7dd3e543 100644 --- a/packages/logic/test/loggingEntries.test.ts +++ b/packages/logic/test/loggingEntries.test.ts @@ -22,4 +22,22 @@ describe('finalizeLogEntry', () => { text: '●193년 12월:이전 달 사건', }); }); + + it('keeps an explicit occurrence time instead of replacing it with the flush time', () => { + const occurredAt = new Date('0200-01-01T00:37:43.000Z'); + const flushAt = new Date('0200-01-01T00:40:00.000Z'); + + expect( + finalizeLogEntry( + { + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + text: '상업 투자를 실행했습니다.', + generalId: 1, + occurredAt, + }, + { year: 200, month: 1, at: flushAt } + )?.createdAt + ).toEqual(occurredAt); + }); });