From 15f6acb4a23235ee2f80821b9cf2931dae13e6d3 Mon Sep 17 00:00:00 2001 From: hided62 Date: Thu, 20 Aug 2026 16:06:07 +0000 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20=EA=B0=9C=EC=9D=B8=20=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D=EC=97=90=20=EC=9E=A5=EC=88=98=EB=B3=84=20=EC=8B=A4?= =?UTF-8?q?=ED=96=89=20=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); + }); }); From c65f4f16b8b7eaa006bd8bcc24272eb89f82e590 Mon Sep 17 00:00:00 2001 From: hided62 Date: Thu, 20 Aug 2026 16:06:47 +0000 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20=EC=82=AC=EC=9A=A9=EC=9E=90=20?= =?UTF-8?q?=EC=B2=A9=EB=B3=B4=20=EB=AA=85=EB=A0=B9=EC=9D=84=20=EA=B8=B0?= =?UTF-8?q?=EB=B3=B8=20=EB=AA=A9=EB=A1=9D=EC=97=90=20=EB=B3=B5=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ref 군사 명령 순서에 첩보를 등록하고 도시 인자 예약과 월초 만료 수명을 API, 엔진, 실제 Chromium 테스트로 고정한다. --- app/game-api/test/commandTable.test.ts | 42 +++++++++++++- .../test/nationTurnCompatibility.test.ts | 27 +++++++++ .../e2e/commandArguments.spec.ts | 58 +++++++++++++++++++ resources/turn-commands/default.json | 1 + 4 files changed, 127 insertions(+), 1 deletion(-) diff --git a/app/game-api/test/commandTable.test.ts b/app/game-api/test/commandTable.test.ts index 15ce9ce8..3958b1da 100644 --- a/app/game-api/test/commandTable.test.ts +++ b/app/game-api/test/commandTable.test.ts @@ -135,7 +135,16 @@ 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_해산'], @@ -221,6 +230,37 @@ describe('buildTurnCommandTable', () => { }); }); + it('exposes the user-only spy command with a city target when the actor can pay the Ref cost', async () => { + const general = { ...buildGeneral(), gold: 300, rice: 300 } as GeneralRow; + const table = await buildTurnCommandTable({ + worldState: buildWorldState(), + general, + city: buildCity(), + nation: buildNation(), + nationGenerals: null, + }); + + const spy = table.general + .find(({ category }) => category === '군사') + ?.values.find(({ key }) => key === 'che_첩보'); + + expect(spy).toMatchObject({ + name: '첩보', + reqArg: true, + possible: true, + status: 'available', + inputFields: [ + { + key: 'destCityId', + label: '대상 도시', + kind: 'select', + required: true, + optionSource: 'cities', + }, + ], + }); + }); + it('uses min-condition constraints for availability', async () => { const table = await buildTurnCommandTable({ worldState: buildWorldState(), diff --git a/app/game-engine/test/nationTurnCompatibility.test.ts b/app/game-engine/test/nationTurnCompatibility.test.ts index 9cdb1163..e085f38e 100644 --- a/app/game-engine/test/nationTurnCompatibility.test.ts +++ b/app/game-engine/test/nationTurnCompatibility.test.ts @@ -224,4 +224,31 @@ describe('레거시 사령부 턴 실행 호환성', () => { }, ]); }); + + it('첩보 도시는 실행 월부터 세 달 보이고 각 월 시작에 감소한 뒤 만료된다', async () => { + const nation = { + id: 1, + meta: { + rate: 20, + spy: { 2: 3 }, + }, + }; + const handler = createNationTurnMonthlyHandler({ + getWorld: () => + ({ + listNations: () => [nation], + updateNation: (_id: number, patch: { meta?: typeof nation.meta }) => { + if (patch.meta) nation.meta = patch.meta; + }, + }) as never, + }); + + expect(nation.meta.spy).toEqual({ 2: 3 }); + await handler.beforeMonthChanged?.({} as never); + expect(nation.meta.spy).toEqual({ 2: 2 }); + await handler.beforeMonthChanged?.({} as never); + expect(nation.meta.spy).toEqual({ 2: 1 }); + await handler.beforeMonthChanged?.({} as never); + expect(nation.meta.spy).toEqual({}); + }); }); diff --git a/app/game-frontend/e2e/commandArguments.spec.ts b/app/game-frontend/e2e/commandArguments.spec.ts index 22248b40..c1d8afa2 100644 --- a/app/game-frontend/e2e/commandArguments.spec.ts +++ b/app/game-frontend/e2e/commandArguments.spec.ts @@ -416,6 +416,22 @@ const commandTable = { }, ], }, + { + key: 'che_첩보', + name: '첩보', + reqArg: true, + possible: true, + status: 'needsInput', + inputFields: [ + { + key: 'destCityId', + label: '대상 도시', + kind: 'select', + required: true, + optionSource: 'cities', + }, + ], + }, ], }, ], @@ -921,6 +937,48 @@ test('renders and accepts every Ref strategy command at mobile width', async ({ await picker.screenshot({ path: test.info().outputPath('all-strategy-commands-mobile.png') }); }); +test('shows and reserves the Ref spy command for a user on desktop and mobile', async ({ page }) => { + const requests = await install(page); + 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'); + await picker.getByRole('button', { name: '군사', exact: true }).click(); + const spy = picker.getByRole('button', { name: '첩보', exact: true }); + await expect(spy).toBeVisible(); + await spy.hover(); + await spy.focus(); + await expect(spy).toBeFocused(); + await spy.click(); + const form = picker.getByTestId('command-argument-form'); + await expect(form.getByTestId('command-argument-guidance')).toContainText( + '선택한 도시에 첩보를 실행합니다.' + ); + await expect(form.getByTestId('command-argument-guidance')).toContainText( + '인접 도시에서는 더 많은 정보를 얻습니다.' + ); + await form.locator('select').selectOption('2'); + await picker.screenshot({ path: test.info().outputPath('spy-command-desktop-1200.png') }); + await picker.getByRole('button', { name: '입력', exact: true }).click(); + await expect(editor.locator('.action-column > div').first()).toHaveText('【허창】에 첩보 실행'); + expect(JSON.stringify(requests)).toContain('"action":"che_첩보","args":{"destCityId":2}'); + + await page.setViewportSize({ width: 500, height: 900 }); + await editor.getByRole('button', { name: '2턴 명령 입력', exact: true }).click(); + picker = page.getByTestId('command-picker'); + await picker.getByRole('button', { name: '군사', exact: true }).click(); + await expect(picker.getByRole('button', { name: '첩보', exact: true })).toBeVisible(); + const geometry = await picker.evaluate((element) => ({ + width: element.getBoundingClientRect().width, + horizontalOverflow: element.scrollWidth - element.clientWidth, + })); + expect(geometry.width).toBeLessThanOrEqual(500); + expect(geometry.horizontalOverflow).toBeLessThanOrEqual(0); + await picker.screenshot({ path: test.info().outputPath('spy-command-mobile-500.png') }); +}); + test('defaults founding to a Ref-selectable nation trait and paints color option labels', async ({ page, }) => { diff --git a/resources/turn-commands/default.json b/resources/turn-commands/default.json index 29a6a649..6553d1f8 100644 --- a/resources/turn-commands/default.json +++ b/resources/turn-commands/default.json @@ -33,6 +33,7 @@ "che_징병", "che_모병", "che_소집해제", + "che_첩보", "che_군량매매", "che_물자조달", "che_증여",