From 31c5927449bf81d730d081fa1ef67b692264be71 Mon Sep 17 00:00:00 2001 From: hided62 Date: Wed, 16 Sep 2026 04:03:14 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=ED=94=8C=EB=A0=88=EC=9D=B4=20=EA=B0=90?= =?UTF-8?q?=EC=82=AC=20=EC=9E=A5=EC=88=98=20=EB=A1=9C=EA=B7=B8=EB=A5=BC=20?= =?UTF-8?q?=EA=B8=B0=EC=88=98=EB=B3=84=EB=A1=9C=20=EC=A1=B0=ED=9A=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/messages/diplomaticResponse.ts | 21 ++-- app/game-api/src/router/playAudit/index.ts | 2 + app/game-api/src/router/playAudit/logs.ts | 60 ++++++++++ app/game-api/test/messagesRouter.test.ts | 13 +++ .../securityTransport.integration.test.ts | 82 ++++++++++++++ .../test/selectPool.integration.test.ts | 4 +- app/game-engine/src/turn/databaseHooks.ts | 11 +- app/game-engine/src/turn/selectPoolService.ts | 2 + ...BoundaryPrePersistence.integration.test.ts | 19 +++- ...tionBettingPersistence.integration.test.ts | 5 +- app/game-frontend/e2e/legacyLogHtml.spec.ts | 1 + app/game-frontend/e2e/playAudit.spec.ts | 70 ++++++++++++ .../components/main/GeneralRecordPanels.vue | 10 +- .../components/playAudit/AuditCityDetail.vue | 2 +- .../playAudit/AuditGeneralDetail.vue | 12 +- .../components/playAudit/AuditGeneralLogs.vue | 106 ++++++++++++++++++ docs/design/play-audit-implementation.md | 34 +++++- packages/infra/prisma/game.prisma | 1 + .../migration.sql | 2 + packages/infra/src/turnEngineDb.ts | 1 + 20 files changed, 437 insertions(+), 21 deletions(-) create mode 100644 app/game-api/src/router/playAudit/logs.ts create mode 100644 app/game-frontend/src/components/playAudit/AuditGeneralLogs.vue create mode 100644 packages/infra/prisma/migrations/20260916020000_add_log_entry_server_id/migration.sql diff --git a/app/game-api/src/messages/diplomaticResponse.ts b/app/game-api/src/messages/diplomaticResponse.ts index a638c9c5..93889b11 100644 --- a/app/game-api/src/messages/diplomaticResponse.ts +++ b/app/game-api/src/messages/diplomaticResponse.ts @@ -59,7 +59,8 @@ const persistLogs = async ( logs: LogEntryDraft[], year: number, month: number, - at: Date + at: Date, + serverId: string | null ): Promise => { const data = logs.flatMap((entry) => { const record = finalizeLogEntry(entry, { year, month, at }); @@ -68,6 +69,7 @@ const persistLogs = async ( } return [ { + serverId, scope: record.scope, category: record.category, subType: record.subType ?? null, @@ -92,7 +94,8 @@ const persistEffects = async ( effects: GeneralActionEffect[], year: number, month: number, - at: Date + at: Date, + serverId: string | null ): Promise => { const logs: LogEntryDraft[] = []; for (const effect of effects) { @@ -122,7 +125,7 @@ const persistEffects = async ( logs.push(effect.entry); } } - await persistLogs(db, orderLegacyActionLoggerFlush(logs), year, month, at); + await persistLogs(db, orderLegacyActionLoggerFlush(logs), year, month, at, serverId); }; const refreshFrontStates = async (db: DatabaseClient, mapName: string, nationIds: number[]): Promise => { @@ -187,11 +190,13 @@ export const respondToDiplomaticMessage = async (options: { } const world = await db.worldState.findFirst({ - select: { currentYear: true, currentMonth: true, config: true }, + select: { currentYear: true, currentMonth: true, config: true, meta: true }, }); if (!world) { throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '게임 상태가 없습니다.' }); } + const serverIdValue = asRecord(world.meta).serverId; + const serverId = typeof serverIdValue === 'string' && serverIdValue.trim() ? serverIdValue : null; const now = (await loadCurrentGameTime(db)).now; const action = parseAction(message.payload.option?.action); if (message.msgType !== 'diplomacy' || !action || message.payload.option?.used) { @@ -204,7 +209,8 @@ export const respondToDiplomaticMessage = async (options: { buildFailureLog(actor.id, reason, actionName, response), world.currentYear, world.currentMonth, - now + now, + serverId ); return { result: false, @@ -302,7 +308,8 @@ export const respondToDiplomaticMessage = async (options: { [...actorLogger.flush(), ...proposerLogger.flush()], world.currentYear, world.currentMonth, - now + now, + serverId ); await invalidateMessages(db, [message.id]); return { @@ -414,7 +421,7 @@ export const respondToDiplomaticMessage = async (options: { ...(action === 'noAggression' ? { treatyYear: treatyYear!, treatyMonth: treatyMonth! } : {}), } ); - await persistEffects(db, resolution.effects, world.currentYear, world.currentMonth, now); + await persistEffects(db, resolution.effects, world.currentYear, world.currentMonth, now, serverId); let affectedCityIds: number[] = []; if (resolution.refreshFront) { const worldConfig = asRecord(world.config); diff --git a/app/game-api/src/router/playAudit/index.ts b/app/game-api/src/router/playAudit/index.ts index b52326e9..eeeda460 100644 --- a/app/game-api/src/router/playAudit/index.ts +++ b/app/game-api/src/router/playAudit/index.ts @@ -1,5 +1,6 @@ import { nationSeries, zAuditNation } from './nationSeries.js'; import { cityDetail, generalDetail, generalTurns } from './details.js'; +import { generalLogs } from './logs.js'; import { z } from 'zod'; import { canReadPlayAuditAccounts } from '@sammo-ts/common'; import { router } from '../../trpc.js'; @@ -22,6 +23,7 @@ import { } from './projection.js'; export const playAuditRouter = router({ + generalLogs, cityDetail, generalDetail, generalTurns, diff --git a/app/game-api/src/router/playAudit/logs.ts b/app/game-api/src/router/playAudit/logs.ts new file mode 100644 index 00000000..35f3ee94 --- /dev/null +++ b/app/game-api/src/router/playAudit/logs.ts @@ -0,0 +1,60 @@ +import { TRPCError } from '@trpc/server'; +import { z } from 'zod'; +import { auditProcedure, monthOrdinal, readAudit, readAuditWorld } from './shared.js'; + +const categoryByType = { + generalHistory: 'HISTORY', + generalAction: 'ACTION', + battleResult: 'BATTLE_BRIEF', + battleDetail: 'BATTLE_DETAIL', +} as const; +export const generalLogs = auditProcedure + .input( + z + .object({ + generalId: z.number().int().nonnegative(), + type: z.enum(['generalHistory', 'generalAction', 'battleResult', 'battleDetail']), + month: z + .object({ year: z.number().int().min(0).max(9999), month: z.number().int().min(1).max(12) }) + .strict() + .optional(), + cursor: z.number().int().positive().optional(), + limit: z.number().int().min(1).max(200).default(50), + }) + .strict() + ) + .query(({ ctx, input }) => + readAudit(ctx, async (tx) => { + const world = await readAuditWorld(tx); + if ( + input.month && + (input.month.year < world.startYear || + monthOrdinal(input.month.year, input.month.month) > monthOrdinal(world.year, world.month)) + ) { + throw new TRPCError({ code: 'BAD_REQUEST', message: '현재 기수 안의 로그 월을 선택해 주세요.' }); + } + const rows = world.serverId + ? await tx.logEntry.findMany({ + where: { + serverId: world.serverId, + generalId: input.generalId, + scope: 'GENERAL', + category: categoryByType[input.type], + id: input.cursor === undefined ? undefined : { lt: input.cursor }, + year: input.month?.year, + month: input.month?.month, + }, + orderBy: { id: 'desc' }, + take: input.limit + 1, + select: { id: true, year: true, month: true, text: true, createdAt: true }, + }) + : []; + return { + ...world, + type: input.type, + coverage: world.serverId ? ('IDENTIFIED_LOGS_ONLY' as const) : ('IDENTITY_MISSING' as const), + items: rows.slice(0, input.limit), + nextCursor: rows.length > input.limit ? rows[input.limit - 1]!.id : null, + }; + }) + ); diff --git a/app/game-api/test/messagesRouter.test.ts b/app/game-api/test/messagesRouter.test.ts index 39fd10fb..75be2116 100644 --- a/app/game-api/test/messagesRouter.test.ts +++ b/app/game-api/test/messagesRouter.test.ts @@ -1049,6 +1049,7 @@ describe('messages router missing-flow compatibility', () => { findFirst: vi.fn(async () => ({ currentYear: 200, currentMonth: 3, + meta: { serverId: 'diplomatic-response-audit' }, config: { environment: { mapName: 'che' } }, clockBaseTime: new Date('0200-03-01T00:00:00.000Z'), clockTick: 1_000n, @@ -1116,6 +1117,9 @@ describe('messages router missing-flow compatibility', () => { cityIds: [], }); expect(setup.diplomacyUpdate).toHaveBeenCalledTimes(2); + expect(setup.logCreateMany).toHaveBeenCalledWith({ + data: expect.arrayContaining([expect.objectContaining({ serverId: 'diplomatic-response-audit' })]), + }); expect(setup.nationUpdate).toHaveBeenCalledWith( expect.objectContaining({ where: { id: 2 }, @@ -1147,6 +1151,9 @@ describe('messages router missing-flow compatibility', () => { expect(setup.diplomacyUpdate).not.toHaveBeenCalled(); expect(setup.messageUpdateMany).toHaveBeenCalledOnce(); expect(setup.logCreateMany).toHaveBeenCalledOnce(); + expect(setup.logCreateMany).toHaveBeenCalledWith({ + data: expect.arrayContaining([expect.objectContaining({ serverId: 'diplomatic-response-audit' })]), + }); }); it('permanently records rejection of an NPC aid-based non-aggression proposal', async () => { @@ -1217,6 +1224,9 @@ describe('messages router missing-flow compatibility', () => { expect(result).toEqual({ result: true, reason: 'success' }); expect(setup.diplomacyUpdate).toHaveBeenCalledTimes(2); + expect(setup.logCreateMany).toHaveBeenCalledWith({ + data: expect.arrayContaining([expect.objectContaining({ serverId: 'diplomatic-response-audit' })]), + }); if (action === 'stopWar') { expect(setup.cityUpdate).toHaveBeenCalledTimes(2); expect(setup.cityUpdate).toHaveBeenCalledWith({ @@ -1255,6 +1265,9 @@ describe('messages router missing-flow compatibility', () => { expect(setup.diplomacyUpdate).not.toHaveBeenCalled(); expect(setup.messageUpdateMany).not.toHaveBeenCalled(); expect(setup.logCreateMany).toHaveBeenCalledOnce(); + expect(setup.logCreateMany).toHaveBeenCalledWith({ + data: expect.arrayContaining([expect.objectContaining({ serverId: 'diplomatic-response-audit' })]), + }); }); it('does not let another nation process the diplomatic inbox row', async () => { diff --git a/app/game-api/test/securityTransport.integration.test.ts b/app/game-api/test/securityTransport.integration.test.ts index bba77a02..1d6a8844 100644 --- a/app/game-api/test/securityTransport.integration.test.ts +++ b/app/game-api/test/securityTransport.integration.test.ts @@ -2238,6 +2238,87 @@ integration('game API security over HTTP transport', () => { }); const admin = await token([`admin.playAudit.read:${profileName}`]); const beforeInputs = await db.inputEvent.count(); + const logGeneralId = 99129; // No live general: death must not hide retained records. + const ownLogs = await Promise.all( + ['HISTORY', 'ACTION', 'BATTLE_BRIEF', 'BATTLE_DETAIL'].map((category) => + db.logEntry.create({ + data: { + serverId: seasonId, + generalId: logGeneralId, + scope: 'GENERAL', + category: category as 'HISTORY' | 'ACTION' | 'BATTLE_BRIEF' | 'BATTLE_DETAIL', + year: 190, + month: 1, + text: `${seasonId}:${category}`, + }, + }) + ) + ); + const latest = await db.logEntry.create({ + data: { + serverId: seasonId, + generalId: logGeneralId, + scope: 'GENERAL', + category: 'HISTORY', + year: 190, + month: 2, + text: `${seasonId}:latest`, + }, + }); + await db.logEntry.createMany({ + data: [null, `${seasonId}:previous`].map((serverId) => ({ + serverId, + generalId: logGeneralId, + scope: 'GENERAL' as const, + category: 'HISTORY' as const, + year: 190, + month: 1, + text: `${seasonId}:excluded`, + })), + }); + for (const [index, type] of ['generalHistory', 'generalAction', 'battleResult', 'battleDetail'].entries()) { + const result = await get('generalLogs', admin, { + generalId: logGeneralId, + type, + month: { year: 190, month: 1 }, + }); + expect(result.status).toBe(200); + expect(result.body).toMatchObject({ + result: { + data: { + coverage: 'IDENTIFIED_LOGS_ONLY', + items: [{ id: ownLogs[index]!.id }], + nextCursor: null, + }, + }, + }); + expect(JSON.stringify(result.body)).not.toContain(`${seasonId}:excluded`); + } + expect( + (await get('generalLogs', admin, { generalId: logGeneralId, type: 'generalHistory', limit: 1 })).body + ).toMatchObject({ result: { data: { items: [{ id: latest.id }], nextCursor: latest.id } } }); + expect( + ( + await get('generalLogs', admin, { + generalId: logGeneralId, + type: 'generalHistory', + limit: 1, + cursor: latest.id, + }) + ).body + ).toMatchObject({ result: { data: { items: [{ id: ownLogs[0]!.id }], nextCursor: null } } }); + for (const invalid of [ + { limit: 201 }, + { cursor: 0 }, + { month: { year: 190, month: 3 } }, + { month: { year: 189, month: 12 } }, + ]) { + expect( + (await get('generalLogs', admin, { generalId: logGeneralId, type: 'generalHistory', ...invalid })) + .status + ).toBe(400); + } + expect((await get('generalDetail', admin, { id: generalId })).body).toMatchObject({ result: { data: { collected: true, general: { id: generalId, name: current.name } } }, }); @@ -2512,6 +2593,7 @@ integration('game API security over HTTP transport', () => { ); await expect.poll(async () => (await get('capabilities', admin)).status).toBe(401); } finally { + await db.logEntry.deleteMany({ where: { text: { startsWith: `${seasonId}:` } } }); await db.playAuditMonth.deleteMany({ where: { serverId: seasonId } }); await db.generalTurn.deleteMany({ where: { generalId, turnIdx: { in: [9001, 9002] } } }); await db.nation.deleteMany({ where: { id: { in: [99121, 99122] } } }); diff --git a/app/game-api/test/selectPool.integration.test.ts b/app/game-api/test/selectPool.integration.test.ts index 99bc3fde..8773a613 100644 --- a/app/game-api/test/selectPool.integration.test.ts +++ b/app/game-api/test/selectPool.integration.test.ts @@ -338,7 +338,7 @@ integration('scenario 903 select pool through the durable turn daemon', () => { ).toBe(initial.name); expect( await db.logEntry.count({ - where: { meta: { path: ['ownerUserId'], equals: userId } }, + where: { serverId: profile, meta: { path: ['ownerUserId'], equals: userId } }, }) ).toBe(2); if (realtimeHub) { @@ -432,7 +432,7 @@ integration('scenario 903 select pool through the durable turn daemon', () => { ).toBe(target.uniqueName); expect( await db.logEntry.count({ - where: { meta: { path: ['ownerUserId'], equals: userId } }, + where: { serverId: profile, meta: { path: ['ownerUserId'], equals: userId } }, }) ).toBe(4); diff --git a/app/game-engine/src/turn/databaseHooks.ts b/app/game-engine/src/turn/databaseHooks.ts index 8f8e7c17..22688376 100644 --- a/app/game-engine/src/turn/databaseHooks.ts +++ b/app/game-engine/src/turn/databaseHooks.ts @@ -619,7 +619,8 @@ const persistNationBettingOpen = async ( const persistNationBettingFinish = async ( prisma: GamePrisma.TransactionClient, finish: PendingNationBettingFinish, - recordsFinalized: boolean + recordsFinalized: boolean, + serverId: string | null ): Promise => { await prisma.$queryRaw` SELECT id @@ -752,6 +753,7 @@ const persistNationBettingFinish = async ( if (finishLog) { await prisma.logEntry.create({ data: { + serverId, scope: finishLog.scope, category: finishLog.category, subType: finishLog.subType ?? null, @@ -1026,7 +1028,7 @@ const buildDiplomacyUpdate = ( const buildLogCreateData = ( entry: LogEntryDraft, - context: { year: number; month: number; at: Date } + context: { year: number; month: number; at: Date; serverId: string | null } ): TurnEngineLogEntryCreateManyInput | null => { const record = finalizeLogEntry(entry, { year: context.year, @@ -1038,6 +1040,7 @@ const buildLogCreateData = ( } return { + serverId: context.serverId, scope: record.scope, category: record.category, subType: record.subType ?? null, @@ -1194,6 +1197,8 @@ export const createDatabaseTurnHooks = async ( }) )?.id ?? 0; const logContext = { + serverId: + typeof state.meta.serverId === 'string' && state.meta.serverId.trim() ? state.meta.serverId : null, year: state.currentYear, month: state.currentMonth, at: state.lastTurnTime, @@ -1474,7 +1479,7 @@ export const createDatabaseTurnHooks = async ( await persistNationBettingOpen(prisma, betting); } for (const finish of pendingNationBettingFinishes) { - await persistNationBettingFinish(prisma, finish, recordsFinalized); + await persistNationBettingFinish(prisma, finish, recordsFinalized, logContext.serverId); } const meta = asRecord(state.meta); diff --git a/app/game-engine/src/turn/selectPoolService.ts b/app/game-engine/src/turn/selectPoolService.ts index dc7b6b11..a409cea3 100644 --- a/app/game-engine/src/turn/selectPoolService.ts +++ b/app/game-engine/src/turn/selectPoolService.ts @@ -669,7 +669,9 @@ const appendSelectionLogs = async (options: { generalText: string; globalText: string; }): Promise => { + const serverId = asRecord(options.worldState.meta).serverId; const common = { + serverId: typeof serverId === 'string' && serverId.trim() ? serverId : null, year: options.worldState.currentYear, month: options.worldState.currentMonth, nationId: null, diff --git a/app/game-engine/test/monthlyBoundaryPrePersistence.integration.test.ts b/app/game-engine/test/monthlyBoundaryPrePersistence.integration.test.ts index 724bdf43..b67b82a8 100644 --- a/app/game-engine/test/monthlyBoundaryPrePersistence.integration.test.ts +++ b/app/game-engine/test/monthlyBoundaryPrePersistence.integration.test.ts @@ -18,7 +18,12 @@ const cityIds = [991_201, 991_202, 991_203, 991_204, 991_205, 991_206, 991_207]; const nationId = 991_201; const yearbookProfile = 'monthly-boundary-pre-persistence'; const yearbookServerId = 'monthly-boundary-generation-20260731'; -const archivedLogTexts = ['월경계 과거 정세', '월경계 과거 장수 동향', '월경계 과거 호환 행동']; +const archivedLogTexts = [ + '월경계 과거 정세', + '월경계 과거 장수 동향', + '월경계 과거 호환 행동', + '월경계 감사 기수 로그', +]; integration('monthly pre-update persistence', () => { let db: GamePrismaClient; @@ -197,6 +202,12 @@ integration('monthly pre-update persistence', () => { const hooks = await createDatabaseTurnHooks(databaseUrl!, world, { profileName: yearbookProfile }); try { await world.advanceMonth(new Date('0201-01-01T00:00:00.000Z')); + world.pushLog({ + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + generalId: generalIds[0], + text: archivedLogTexts[3]!, + }); await hooks.hooks.flushChanges?.({ lastTurnTime: '0201-01-01T00:00:00.000Z', processedGenerals: 0, @@ -205,6 +216,12 @@ integration('monthly pre-update persistence', () => { partial: false, }); + expect(await db.logEntry.findFirst({ where: { text: archivedLogTexts[3] } })).toMatchObject({ + serverId: yearbookServerId, + }); + expect(await db.logEntry.findFirst({ where: { text: archivedLogTexts[0] } })).toMatchObject({ + serverId: null, + }); expect( await db.generalAccessLog.findMany({ where: { generalId: { in: generalIds } }, diff --git a/app/game-engine/test/monthlyNationBettingPersistence.integration.test.ts b/app/game-engine/test/monthlyNationBettingPersistence.integration.test.ts index 167de243..ae6dca85 100644 --- a/app/game-engine/test/monthlyNationBettingPersistence.integration.test.ts +++ b/app/game-engine/test/monthlyNationBettingPersistence.integration.test.ts @@ -214,7 +214,7 @@ integration('monthly nation betting persistence', () => { currentMonth: 12, tickSeconds: 600, config: {}, - meta: { lastBettingId: bettingId - 1 }, + meta: { lastBettingId: bettingId - 1, serverId: 'audit-betting-fixture' }, }, }); const state: TurnWorldState = { @@ -223,7 +223,7 @@ integration('monthly nation betting persistence', () => { currentMonth: 12, tickSeconds: 600, lastTurnTime: new Date('2026-07-25T00:00:00.000Z'), - meta: { lastBettingId: bettingId - 1 }, + meta: { lastBettingId: bettingId - 1, serverId: 'audit-betting-fixture' }, }; const scenarioConfig: TurnWorldSnapshot['scenarioConfig'] = { stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 75, npcMin: 10, chiefMin: 70 }, @@ -356,6 +356,7 @@ integration('monthly nation betting persistence', () => { where: { text: { contains: '천통국 예상 내기의 결과' } }, }) ).toMatchObject({ + serverId: 'audit-betting-fixture', year: 200, month: 2, text: '●200년 2월:【내기】 200년 1월에 열렸던 천통국 예상 내기의 결과가 나왔습니다!', diff --git a/app/game-frontend/e2e/legacyLogHtml.spec.ts b/app/game-frontend/e2e/legacyLogHtml.spec.ts index e1e1efee..597192bf 100644 --- a/app/game-frontend/e2e/legacyLogHtml.spec.ts +++ b/app/game-frontend/e2e/legacyLogHtml.spec.ts @@ -30,6 +30,7 @@ const history = [ ]; const publicResponse = (operation: string): unknown => { + if (operation === 'lobby.info') return response({ myGeneral: null }); if (operation === 'public.getMapLayout') return response({ mapName: 'che', cityList: [] }); if (operation === 'public.getCachedMap') { return response({ year: 200, month: 1, cityList: [], nationList: [], history }); diff --git a/app/game-frontend/e2e/playAudit.spec.ts b/app/game-frontend/e2e/playAudit.spec.ts index dcab57a8..a12263ba 100644 --- a/app/game-frontend/e2e/playAudit.spec.ts +++ b/app/game-frontend/e2e/playAudit.spec.ts @@ -71,6 +71,22 @@ const install = async (page: Page, denied = false) => { }, } : result({ profileName: gameProfile, read: true, accounts: false }); + case 'playAudit.generalLogs': + return result({ + ...world, + type: input.type, + coverage: 'IDENTIFIED_LOGS_ONLY', + items: [ + { + id: 1, + year: 190, + month: 1, + text: `${input.type} 감사 로그`, + createdAt: '0190-01-01T00:00:00.000Z', + }, + ], + nextCursor: null, + }); case 'playAudit.coverage': return result({ ...world, status: 'COLLECTED', samples: [], nextCursor: null }); case 'playAudit.nations': @@ -404,3 +420,57 @@ test('city detail is addressable without reloading the list and retains month fo at: { year: 190, month: 6, kind: 'MONTH_END' }, }); }); + +test('general logs load explicitly and cache each category without reloading entity lists', async ({ page }) => { + const requests = await install(page); + await page.goto(gamePath('/play-audit?tab=generals&general=1')); + await expect(page.getByRole('button', { name: '장수 기록 조회', exact: true })).toBeVisible(); + expect(requests.filter((r) => r.operation === 'playAudit.generalLogs')).toHaveLength(0); + const listCount = requests.filter((r) => r.operation === 'playAudit.generals').length; + await page.getByRole('button', { name: '장수 기록 조회', exact: true }).click(); + await expect(page.getByText('generalHistory 감사 로그', { exact: false })).toBeVisible(); + await page.getByLabel('기록 종류').selectOption('generalAction'); + await expect(page.getByText('generalAction 감사 로그', { exact: false })).toBeVisible(); + await page.getByLabel('기록 종류').selectOption('generalHistory'); + await expect(page.getByText('generalHistory 감사 로그', { exact: false })).toBeVisible(); + expect(requests.filter((r) => r.operation === 'playAudit.generalLogs')).toHaveLength(2); + expect(requests.filter((r) => r.operation === 'playAudit.generals')).toHaveLength(listCount); + expect(await page.evaluate(() => Reflect.get(window, 'auditInjected'))).toBeUndefined(); + await expect(page.locator('.audit-logs script')).toHaveCount(0); + await capture(page, 'general-logs'); +}); + +test('historical log failure retries independently and sends only the selected month', async ({ page }) => { + const requests = await install(page); + let fail = true; + await page.route(gameTrpcRoute, async (route) => { + if (decodeURIComponent(route.request().url()).includes('playAudit.generalLogs') && fail) { + fail = false; + await route.fulfill({ + status: 500, + contentType: 'application/json', + body: JSON.stringify([ + { + error: { + message: '기록 조회 재시도', + code: -32603, + data: { code: 'INTERNAL_SERVER_ERROR', httpStatus: 500 }, + }, + }, + ]), + }); + } else await route.fallback(); + }); + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto(gamePath('/play-audit?tab=generals&general=1&at=month&year=190&month=6')); + await page.getByRole('button', { name: '장수 기록 조회', exact: true }).click(); + await expect(page.getByRole('alert')).toContainText('기록 조회 재시도'); + await page.getByRole('button', { name: '다시 조회', exact: true }).click(); + await expect(page.getByText('generalHistory 감사 로그', { exact: false })).toBeVisible(); + expect(requests.filter((r) => r.operation === 'playAudit.generalLogs')[0]?.input).toMatchObject({ + generalId: 1, + month: { year: 190, month: 6 }, + }); + expect(requests.filter((r) => r.operation === 'playAudit.generalTurns')).toHaveLength(0); + await capture(page, 'historical-general-logs-mobile'); +}); diff --git a/app/game-frontend/src/components/main/GeneralRecordPanels.vue b/app/game-frontend/src/components/main/GeneralRecordPanels.vue index 61e97e4b..625c324e 100644 --- a/app/game-frontend/src/components/main/GeneralRecordPanels.vue +++ b/app/game-frontend/src/components/main/GeneralRecordPanels.vue @@ -8,13 +8,18 @@ const props = withDefaults( loading?: boolean; trustedHtml?: boolean; unavailable?: GeneralRecordType[]; + types?: GeneralRecordType[]; + errors?: Partial>; }>(), { loading: false, trustedHtml: false, unavailable: () => [], + types: () => [...GENERAL_RECORD_TYPES], + errors: () => ({}), } ); +defineEmits<{ retry: [type: GeneralRecordType] }>(); const labels: Record = { generalHistory: '장수 열전', @@ -33,9 +38,12 @@ const unavailableText: Record = {