From e9d8961386879f731b2fbb49a172bf3c8a8fb816 Mon Sep 17 00:00:00 2001 From: hided62 Date: Thu, 20 Aug 2026 16:23:40 +0000 Subject: [PATCH] =?UTF-8?q?fix:=20=ED=84=B4=20=EC=8B=A4=ED=8C=A8=20?= =?UTF-8?q?=EC=82=AC=EC=9C=A0=EB=A5=BC=20=EC=8B=A4=ED=96=89=20=EC=9E=A5?= =?UTF-8?q?=EC=88=98=EC=9D=98=20=EA=B0=9C=EC=9D=B8=20=EA=B8=B0=EB=A1=9D?= =?UTF-8?q?=EC=97=90=20=EB=82=A8=EA=B9=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 개인턴과 수뇌턴 공통 로그 생성 경로에서 generalId를 필수로 전달해 PostgreSQL 영속화 단계의 누락을 막는다. 비교 도구의 소유자 추론을 제거하고 단위, PostgreSQL, Chromium 회귀 검증을 추가한다. --- .../src/turn/reservedTurnHandler.ts | 83 +++++++---- .../test/reservedTurnExecution.test.ts | 63 +++++++- ...nFailureLogPersistence.integration.test.ts | 137 ++++++++++++++++++ app/game-frontend/e2e/inGameMenus.spec.ts | 56 +++++++ .../src/turn-differential/coreCommandTrace.ts | 6 +- 5 files changed, 308 insertions(+), 37 deletions(-) create mode 100644 app/game-engine/test/turnFailureLogPersistence.integration.test.ts diff --git a/app/game-engine/src/turn/reservedTurnHandler.ts b/app/game-engine/src/turn/reservedTurnHandler.ts index aca2f06b..ae08f39c 100644 --- a/app/game-engine/src/turn/reservedTurnHandler.ts +++ b/app/game-engine/src/turn/reservedTurnHandler.ts @@ -156,15 +156,15 @@ export const applyLegacyGeneralProgression = ( meta.explevel = expLevel; if (expLevel !== previousExpLevel && actionResolvedExpLevel !== expLevel) { const josaRo = JosaUtil.pick(String(expLevel), '로'); - logs.push({ - scope: LogScope.GENERAL, - category: LogCategory.ACTION, - format: LogFormat.PLAIN, - text: + logs.push( + createGeneralActionLog( + general.id, expLevel > previousExpLevel ? `Lv ${expLevel}${josaRo} 레벨업!` : `Lv ${expLevel}${josaRo} 레벨다운!`, - }); + { format: LogFormat.PLAIN } + ) + ); } } if (!preserveLevel && (forceRefreshLevel || general.dedication !== previousGeneral.dedication)) { @@ -176,15 +176,15 @@ export const applyLegacyGeneralProgression = ( const billText = getBillByLevel(dedicationLevel).toLocaleString('en-US'); const josaRoDedication = JosaUtil.pick(dedicationLevelText, '로'); const josaRoBill = JosaUtil.pick(billText, '로'); - logs.push({ - scope: LogScope.GENERAL, - category: LogCategory.ACTION, - format: LogFormat.PLAIN, - text: + logs.push( + createGeneralActionLog( + general.id, dedicationLevel > previousDedicationLevel ? `${dedicationLevelText}${josaRoDedication} 승급하여 봉록이 ${billText}${josaRoBill} 상승했습니다!` : `${dedicationLevelText}${josaRoDedication} 강등되어 봉록이 ${billText}${josaRoBill} 하락했습니다!`, - }); + { format: LogFormat.PLAIN } + ) + ); } } @@ -715,12 +715,27 @@ const buildConstraintContext = ( mode: 'full', }); -const createActionLog = (message: string, meta?: Record): LogEntryDraft => ({ +/** + * Ref ActionLogger is constructed with a general ID, so every personal action + * log carries its owner before it reaches persistence. Keep that ownership + * explicit here: finalizeLogEntry intentionally rejects ownerless GENERAL logs. + */ +interface GeneralActionLogOptions { + format?: LogFormat; + meta?: Record; +} + +const createGeneralActionLog = ( + generalId: number, + message: string, + options: GeneralActionLogOptions = {} +): LogEntryDraft => ({ scope: LogScope.GENERAL, category: LogCategory.ACTION, - format: LogFormat.MONTH, + generalId, + format: options.format ?? LogFormat.MONTH, text: message, - meta, + ...(options.meta ? { meta: options.meta } : {}), }); const resolveDefinition = ( @@ -936,7 +951,7 @@ export const createReservedTurnHandler = async (options: { actionKey = definition.key; usedFallback = true; blockedReason = failureText; - logs.push(createActionLog(failureText)); + logs.push(createGeneralActionLog(currentGeneral.id, failureText)); } const actionConstraintEnv = { @@ -972,7 +987,7 @@ export const createReservedTurnHandler = async (options: { const failureText = failedDefinition.formatConstraintFailure?.(reason, constraintCtx, failedActionArgs, view) ?? `${reason} ${failedDefinition.name} 실패.`; - logs.push(createActionLog(failureText, meta)); + logs.push(createGeneralActionLog(currentGeneral.id, failureText, meta ? { meta } : {})); } if (!usedFallback && (kind === 'general' || currentNation)) { const currentYearMonth = joinYearMonth(context.world.currentYear, context.world.currentMonth); @@ -987,7 +1002,7 @@ export const createReservedTurnHandler = async (options: { actionKey = definition.key; usedFallback = true; blockedReason = `${remainTurn}턴 더 기다려야 합니다`; - logs.push(createActionLog(blockedReason)); + logs.push(createGeneralActionLog(currentGeneral.id, blockedReason)); } } @@ -1068,7 +1083,7 @@ export const createReservedTurnHandler = async (options: { actionKey = definition.key; usedFallback = true; blockedReason = '예약된 명령을 실행하지 못했습니다.'; - logs.push(createActionLog('예약된 명령을 실행하지 못했습니다.')); + logs.push(createGeneralActionLog(currentGeneral.id, '예약된 명령을 실행하지 못했습니다.')); actionRng = sharedActionRng ?? buildRng(actionKey); baseContext = { general: currentGeneral, @@ -1151,7 +1166,7 @@ export const createReservedTurnHandler = async (options: { const progressText = executionDefinition.getProgressText?.(actionContext, actionArgs, nextTerm, termMax) ?? `${definition.name} 수행중... (${nextTerm}/${termMax})`; - logs.push(createActionLog(progressText)); + logs.push(createGeneralActionLog(currentGeneral.id, progressText)); return { actionKey, usedFallback, completed: false, blockedReason }; } } @@ -1576,7 +1591,7 @@ export const createReservedTurnHandler = async (options: { }, rng: preprocessRng, log: { - push: (message) => logs.push(createActionLog(message)), + push: (message) => logs.push(createGeneralActionLog(currentGeneral.id, message)), }, }); preTurnPipeline.getPreTurnExecuteTriggerList(preTurnContext).fire(preTurnContext, baseConstraintEnv); @@ -1602,7 +1617,12 @@ export const createReservedTurnHandler = async (options: { } currentGeneral.crew = 0; currentGeneral.rice = 0; - logs.push(createActionLog('군량이 모자라 병사들이 소집해제되었습니다!')); + logs.push( + createGeneralActionLog( + currentGeneral.id, + '군량이 모자라 병사들이 소집해제되었습니다!' + ) + ); preTurnContext.skill.activate('pre.소집해제'); } preTurnContext.skill.activate('pre.병력군량소모'); @@ -1625,7 +1645,8 @@ export const createReservedTurnHandler = async (options: { if (isBlocked) { currentGeneral.meta.killturn = Math.max(0, currentGeneral.meta.killturn - 1); logs.push( - createActionLog( + createGeneralActionLog( + currentGeneral.id, blockCode === 2 ? '현재 멀티, 또는 비매너로 인한블럭 대상자입니다.' : '현재 악성유저로 분류되어 블럭 대상자입니다.' @@ -1981,7 +2002,8 @@ export const createReservedTurnHandler = async (options: { ? currentGeneral.meta.owner_name : currentGeneral.userId; logs.push( - createActionLog( + createGeneralActionLog( + currentGeneral.id, `${ownerName ?? '사용자'}이 ${currentGeneral.name}의 육체에서 유체이탈합니다!` ) ); @@ -2060,7 +2082,8 @@ export const createReservedTurnHandler = async (options: { chiefGeneralId: successor.id, }; logs.push( - createActionLog( + createGeneralActionLog( + currentGeneral.id, `${successor.name}이 ${currentNation.name}의 유지를 이어 받았습니다` ) ); @@ -2093,7 +2116,12 @@ export const createReservedTurnHandler = async (options: { if (!deleteGeneral && currentGeneral.age >= retirementYear && currentGeneral.npcState === 0) { currentGeneral = resetRetiredGeneral(currentGeneral); lifecycleOutcome = 'retired'; - logs.push(createActionLog('나이가 들어 은퇴하고 자손에게 자리를 물려줍니다.')); + logs.push( + createGeneralActionLog( + currentGeneral.id, + '나이가 들어 은퇴하고 자손에게 자리를 물려줍니다.' + ) + ); } currentGeneral = { @@ -2246,8 +2274,7 @@ export const createImmediateGeneralActionExecutor = async (options: { `${reason} ${definition.name} 실패.`; if (input.actionKey === 'che_접경귀환' || input.actionKey === 'che_등용수락') { options.world.pushLog({ - ...createActionLog(failureText), - generalId: general.id, + ...createGeneralActionLog(general.id, failureText), }); } return { ok: false, reason: failureText }; diff --git a/app/game-engine/test/reservedTurnExecution.test.ts b/app/game-engine/test/reservedTurnExecution.test.ts index fff9e833..b20be1fc 100644 --- a/app/game-engine/test/reservedTurnExecution.test.ts +++ b/app/game-engine/test/reservedTurnExecution.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; -import type { TurnSchedule } from '@sammo-ts/logic'; +import { finalizeLogEntry, type TurnSchedule } from '@sammo-ts/logic'; import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; import { InMemoryReservedTurnStore } from '../src/turn/reservedTurnStore.js'; @@ -41,8 +41,9 @@ const mockDate = new Date('0189-01-01T00:00:00Z'); // We need a mock Prisma client that satisfies the shape required by InMemoryReservedTurnStore // It expects { generalTurn: { findMany, deleteMany, createMany }, nationTurn: { ... } } -const createMockPrisma = (initialGeneralRows: any[] = []) => { +const createMockPrisma = (initialGeneralRows: any[] = [], initialNationRows: any[] = []) => { let generalRows = [...initialGeneralRows]; + let nationRows = [...initialNationRows]; return { generalTurn: { findMany: vi.fn(async ({ where } = {}) => { @@ -67,9 +68,28 @@ const createMockPrisma = (initialGeneralRows: any[] = []) => { }), }, nationTurn: { - findMany: vi.fn(async () => []), - deleteMany: vi.fn(async () => ({ count: 0 })), - createMany: vi.fn(async () => ({ count: 0 })), + findMany: vi.fn(async ({ where } = {}) => { + if (where?.nationId && where?.officerLevel) { + return nationRows + .filter((row) => row.nationId === where.nationId && row.officerLevel === where.officerLevel) + .sort((left, right) => left.turnIdx - right.turnIdx); + } + return nationRows; + }), + deleteMany: vi.fn(async ({ where } = {}) => { + if (where?.nationId && where?.officerLevel) { + nationRows = nationRows.filter( + (row) => row.nationId !== where.nationId || row.officerLevel !== where.officerLevel + ); + } + return { count: 0 }; + }), + createMany: vi.fn(async ({ data }) => { + if (Array.isArray(data)) { + nationRows.push(...data); + } + return { count: data.length }; + }), }, }; }; @@ -423,8 +443,17 @@ describe('Reserved Turn Execution Integration', () => { }; const invalidRows = [{ generalId: 1, turnIdx: 0, actionCode: 'che_이동', arg: { destCityId: 'bad' } }]; + const invalidNationRows = [ + { + nationId: 1, + officerLevel: 5, + turnIdx: 0, + actionCode: 'che_천도', + arg: { destCityId: 'bad' }, + }, + ]; - const mockPrisma = createMockPrisma(invalidRows); + const mockPrisma = createMockPrisma(invalidRows, invalidNationRows); const reservedTurnStore = new InMemoryReservedTurnStore(mockPrisma as any, { maxGeneralTurns: 10, maxNationTurns: 10, @@ -456,7 +485,26 @@ describe('Reserved Turn Execution Integration', () => { const dirty = world.consumeDirtyState(); expect(world.getGeneralById(1)!.cityId).toBe(1); - expect(dirty.logs.some((log) => log.text.includes('인자가 올바르지 않습니다. 이동 실패.'))).toBe(true); + expect(dirty.logs.find((log) => log.text.includes('인자가 올바르지 않습니다. 천도 실패.'))).toMatchObject({ + scope: 'GENERAL', + category: 'ACTION', + generalId: 1, + }); + expect(dirty.logs.find((log) => log.text.includes('인자가 올바르지 않습니다. 이동 실패.'))).toMatchObject({ + scope: 'GENERAL', + category: 'ACTION', + generalId: 1, + }); + const personalActionLogs = dirty.logs.filter( + (log) => log.scope === 'GENERAL' && log.category === 'ACTION' + ); + expect(personalActionLogs.length).toBeGreaterThan(0); + expect(personalActionLogs.every((log) => log.generalId === 1)).toBe(true); + expect( + personalActionLogs.map((log) => + finalizeLogEntry(log, { year: invalidState.currentYear, month: invalidState.currentMonth }) + ) + ).not.toContain(null); expect(dirty.logs.some((log) => log.text.includes('아무것도 실행하지 않았습니다.'))).toBe(true); }); @@ -620,6 +668,7 @@ describe('Reserved Turn Execution Integration', () => { const denyLog = dirty.logs.find((log) => log.text.includes('같은 도시입니다.')); expect(denyLog?.text).toContain('이동 실패.'); expect(denyLog?.meta?.constraintName).toBe('notSameDestCity'); + expect(denyLog?.generalId).toBe(1); expect(dirty.logs.some((log) => log.text.includes('아무것도 실행하지 않았습니다.'))).toBe(true); }); diff --git a/app/game-engine/test/turnFailureLogPersistence.integration.test.ts b/app/game-engine/test/turnFailureLogPersistence.integration.test.ts new file mode 100644 index 00000000..6f3bada5 --- /dev/null +++ b/app/game-engine/test/turnFailureLogPersistence.integration.test.ts @@ -0,0 +1,137 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { createGamePostgresConnector, type GamePrismaClient, type InputJsonValue } from '@sammo-ts/infra'; +import { LogCategory, LogFormat, LogScope, type TurnSchedule } from '@sammo-ts/logic'; + +import { createDatabaseTurnHooks, type DatabaseTurnHooks } from '../src/turn/databaseHooks.js'; +import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; +import type { TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; + +const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL; +const integration = describe.skipIf(!databaseUrl); +const worldId = 2_146_200_820; +const generalId = 2_146_200_821; +const turnTime = new Date('0190-01-01T00:00:00.000Z'); +const turnRunResult = { + lastTurnTime: turnTime.toISOString(), + processedGenerals: 1, + processedTurns: 1, + durationMs: 0, + partial: false, +} as const; + +const schedule: TurnSchedule = { + entries: [{ startMinute: 0, tickMinutes: 10 }], +}; + +const state: TurnWorldState = { + id: worldId, + currentYear: 190, + currentMonth: 1, + tickSeconds: 600, + lastTurnTime: turnTime, + meta: {}, +}; + +const snapshot: TurnWorldSnapshot = { + generals: [], + cities: [], + nations: [], + troops: [], + diplomacy: [], + events: [], + initialEvents: [], + map: { + id: 'turn-failure-log-persistence', + name: '턴 실패 로그 영속화', + cities: [], + defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 }, + }, + scenarioConfig: { + stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 }, + iconPath: '', + map: {}, + const: {}, + environment: { mapName: 'che', unitSet: 'che' }, + }, + scenarioMeta: { + title: '턴 실패 로그 영속화', + startYear: 190, + life: null, + fiction: null, + history: [], + ignoreDefaultEvents: false, + }, +}; + +integration('turn failure personal-record persistence', () => { + let db: GamePrismaClient; + let disconnect: (() => Promise) | undefined; + let databaseHooks: DatabaseTurnHooks | undefined; + + const cleanup = async () => { + await db.logEntry.deleteMany({ where: { generalId } }); + await db.worldState.deleteMany({ where: { id: worldId } }); + }; + + beforeAll(async () => { + const connector = createGamePostgresConnector({ url: databaseUrl! }); + await connector.connect(); + db = connector.prisma; + disconnect = () => connector.disconnect(); + await cleanup(); + }); + + afterAll(async () => { + await databaseHooks?.close(); + await cleanup(); + await disconnect?.(); + }); + + it('stores personal and nation-turn failure reasons under the acting general', async () => { + await db.worldState.create({ + data: { + id: worldId, + scenarioCode: 'turn-failure-log-persistence', + currentYear: state.currentYear, + currentMonth: state.currentMonth, + tickSeconds: state.tickSeconds, + config: snapshot.scenarioConfig as unknown as InputJsonValue, + meta: {}, + }, + }); + + const world = new InMemoryTurnWorld(state, snapshot, { schedule }); + world.pushLog({ + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + generalId, + format: LogFormat.MONTH, + text: '대상 도시가 아국이 아닙니다. 발령 실패.', + }); + world.pushLog({ + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + generalId, + format: LogFormat.MONTH, + text: '같은 도시입니다. 이동 실패.', + }); + + databaseHooks = await createDatabaseTurnHooks(databaseUrl!, world); + await databaseHooks.hooks.flushChanges?.(turnRunResult); + + const records = await db.logEntry.findMany({ + where: { + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + generalId, + }, + orderBy: { id: 'asc' }, + select: { generalId: true, text: true }, + }); + + expect(records).toEqual([ + { generalId, text: '●1월:대상 도시가 아국이 아닙니다. 발령 실패.' }, + { generalId, text: '●1월:같은 도시입니다. 이동 실패.' }, + ]); + }); +}); diff --git a/app/game-frontend/e2e/inGameMenus.spec.ts b/app/game-frontend/e2e/inGameMenus.spec.ts index 54ab7cd7..859949a7 100644 --- a/app/game-frontend/e2e/inGameMenus.spec.ts +++ b/app/game-frontend/e2e/inGameMenus.spec.ts @@ -827,6 +827,62 @@ test('메인 장수 동향과 개인 전투 기록은 Ref 행 간격·색상· await persistParityArtifact(page, 'core-main-personal-battle-log-inline-mobile', mobileGeometry); }); +test('개인턴·수뇌턴 실패 사유를 메인 개인 기록에 표시한다', async ({ page }) => { + const state: FixtureState = { + permission: 'head', + myset: 3, + settingMutations: [], + accessPages: [], + recentRecords: { + global: [], + general: [ + { + id: 19002, + text: '●1월:대상 도시가 아국이 아닙니다. 여포 발령 실패.', + createdAt: '2026-01-01T03:55:00.000Z', + }, + { + id: 19001, + text: '●1월:같은 도시입니다. 으로 이동 실패.', + createdAt: '2026-01-01T03:54:00.000Z', + }, + ], + history: [], + }, + }; + await install(page, state); + await page.setViewportSize({ width: 1200, height: 900 }); + await page.goto(''); + + const inspectFailureLogs = async (selector: string) => { + const lines = page.locator(selector); + await expect(lines).toHaveCount(2); + await expect(lines.nth(0)).toContainText('대상 도시가 아국이 아닙니다. 여포 발령 실패. 12:55'); + await expect(lines.nth(1)).toContainText('같은 도시입니다. 업으로 이동 실패. 12:54'); + return lines.evaluateAll((elements) => + elements.map((element) => { + const rect = element.getBoundingClientRect(); + const style = getComputedStyle(element); + return { + text: element.textContent?.trim(), + width: rect.width, + height: rect.height, + lineHeight: style.lineHeight, + }; + }) + ); + }; + + const desktop = await inspectFailureLogs('.record-zone [data-record-bucket="general"] .record-line'); + expect(desktop.every((line) => line.width > 0 && line.height === 21 && line.lineHeight === '21px')).toBe(true); + await persistParityArtifact(page, 'core-main-turn-failure-personal-records-desktop', desktop); + + await page.setViewportSize({ width: 500, height: 900 }); + const mobile = await inspectFailureLogs('.record-zone-mobile [data-record-bucket="general"] .record-line'); + expect(mobile.every((line) => line.width > 0 && line.height === 21 && line.lineHeight === '21px')).toBe(true); + await persistParityArtifact(page, 'core-main-turn-failure-personal-records-mobile', mobile); +}); + test('전투시드는 메인·내 정보·감찰부에서 숨긴 채 선택할 수 있다', async ({ page }) => { const seedText = '(전투시드: 0123456789abcdef)'; const logText = diff --git a/tools/integration-tests/src/turn-differential/coreCommandTrace.ts b/tools/integration-tests/src/turn-differential/coreCommandTrace.ts index e9543045..4f3f7ec2 100644 --- a/tools/integration-tests/src/turn-differential/coreCommandTrace.ts +++ b/tools/integration-tests/src/turn-differential/coreCommandTrace.ts @@ -888,8 +888,10 @@ export const runCoreTurnCommandTrace = async ( id: index + 1, scope: log.scope, category: log.category, - generalId: log.generalId ?? (log.scope === 'GENERAL' ? actor.id : undefined), - nationId: log.nationId ?? (log.scope === 'NATION' ? actor.nationId : undefined), + // Keep the product draft unchanged. Inferring an owner here hid + // GENERAL logs that finalizeLogEntry would reject in production. + generalId: log.generalId, + nationId: log.nationId, year: state.currentYear, month: state.currentMonth, text: log.text,