From 7f12b072b3d1e4d650db0febeb81b25e49e6cb61 Mon Sep 17 00:00:00 2001 From: hided62 Date: Fri, 31 Jul 2026 14:10:28 +0000 Subject: [PATCH] fix(api): resolve yearbooks by generation server id --- app/game-api/src/router/yearbook/index.ts | 94 ++++++++++++------- .../test/yearbookArchiveRouter.test.ts | 52 +++++++++- .../visual-parity.spec.ts | 1 + 3 files changed, 110 insertions(+), 37 deletions(-) diff --git a/app/game-api/src/router/yearbook/index.ts b/app/game-api/src/router/yearbook/index.ts index 51ac6c1..7f825a3 100644 --- a/app/game-api/src/router/yearbook/index.ts +++ b/app/game-api/src/router/yearbook/index.ts @@ -63,6 +63,27 @@ const parseYearbookNations = (value: unknown): YearbookNation[] => { return output; }; +const resolveArchiveTarget = ( + worldMeta: unknown, + profileName: string, + requestedServerId?: string +): { archiveKey: string; legacyAlias: string | null; isCurrentProfile: boolean } => { + const rawServerId = asRecord(worldMeta).serverId; + const canonicalServerId = typeof rawServerId === 'string' && rawServerId.trim() ? rawServerId.trim() : profileName; + const requested = requestedServerId ?? profileName; + const isCurrentProfile = requested === profileName || requested === canonicalServerId; + return { + archiveKey: isCurrentProfile ? canonicalServerId : requested, + legacyAlias: isCurrentProfile && canonicalServerId !== profileName ? profileName : null, + isCurrentProfile, + }; +}; + +const normalizeArchivedLogs = (value: unknown, month: number): string[] => { + const logs = parseTextArray(value); + return logs.length ? logs : [`●${month}월: 기록 없음`]; +}; + const buildNationSnapshot = async (ctx: GameApiContext) => { const [nationRows, cityRows, generalRows] = await Promise.all([ ctx.db.nation.findMany({ @@ -220,22 +241,27 @@ export const yearbookRouter = router({ message: 'World state is not initialized.', }); } - const targetProfileName = input?.serverID ?? ctx.profile.name; - const isCurrentProfile = targetProfileName === ctx.profile.name; + const target = resolveArchiveTarget(worldState.meta, ctx.profile.name, input?.serverID); - const firstRow = await ctx.db.yearbookHistory.findFirst({ - where: { profileName: targetProfileName }, - select: { year: true, month: true }, - orderBy: [{ year: 'asc' }, { month: 'asc' }], - }); + const findRange = async (profileName: string) => + Promise.all([ + ctx.db.yearbookHistory.findFirst({ + where: { profileName }, + select: { year: true, month: true }, + orderBy: [{ year: 'asc' as const }, { month: 'asc' as const }], + }), + ctx.db.yearbookHistory.findFirst({ + where: { profileName }, + select: { year: true, month: true }, + orderBy: [{ year: 'desc' as const }, { month: 'desc' as const }], + }), + ]); + let [firstRow, lastRow] = await findRange(target.archiveKey); + if ((!firstRow || !lastRow) && target.legacyAlias) { + [firstRow, lastRow] = await findRange(target.legacyAlias); + } - const lastRow = await ctx.db.yearbookHistory.findFirst({ - where: { profileName: targetProfileName }, - select: { year: true, month: true }, - orderBy: [{ year: 'desc' }, { month: 'desc' }], - }); - - if (!isCurrentProfile && (!firstRow || !lastRow)) { + if (!target.isCurrentProfile && (!firstRow || !lastRow)) { throw new TRPCError({ code: 'NOT_FOUND', message: '연감 범위를 찾을 수 없습니다.' }); } @@ -243,7 +269,7 @@ export const yearbookRouter = router({ const fallbackYearMonth = currentYearMonth - 1; const firstYearMonth = firstRow ? joinYearMonth(firstRow.year, firstRow.month) : fallbackYearMonth; const lastYearMonth = lastRow ? joinYearMonth(lastRow.year, lastRow.month) : fallbackYearMonth; - const selectedYearMonth = isCurrentProfile ? currentYearMonth : lastYearMonth; + const selectedYearMonth = target.isCurrentProfile ? currentYearMonth : lastYearMonth; return { firstYearMonth, @@ -266,15 +292,16 @@ export const yearbookRouter = router({ if (!worldState) { throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'World state is not initialized.' }); } - const targetProfileName = input.serverID ?? ctx.profile.name; - const isCurrentProfile = targetProfileName === ctx.profile.name; - const shouldRecordAfterHashCheck = isCurrentProfile && Boolean(input.hash); - if (isCurrentProfile && !shouldRecordAfterHashCheck) { + const target = resolveArchiveTarget(worldState.meta, ctx.profile.name, input.serverID); + const shouldRecordAfterHashCheck = target.isCurrentProfile && Boolean(input.hash); + if (target.isCurrentProfile && !shouldRecordAfterHashCheck) { await recordHistoryAccess(ctx); } const isCurrent = - isCurrentProfile && worldState.currentYear === input.year && worldState.currentMonth === input.month; + target.isCurrentProfile && + worldState.currentYear === input.year && + worldState.currentMonth === input.month; if (isCurrent) { const { globalHistory, globalAction } = await buildLogs(ctx, input.year, input.month); @@ -301,28 +328,23 @@ export const yearbookRouter = router({ return { notModified: false, hash, data }; } - const row = await ctx.db.yearbookHistory.findFirst({ - where: { - profileName: targetProfileName, - year: input.year, - month: input.month, - }, - orderBy: [{ sourceId: 'desc' }, { id: 'desc' }], - }); + const findArchivedRow = (profileName: string) => + ctx.db.yearbookHistory.findFirst({ + where: { profileName, year: input.year, month: input.month }, + orderBy: [{ sourceId: 'desc' as const }, { id: 'desc' as const }], + }); + let row = await findArchivedRow(target.archiveKey); + if (!row && target.legacyAlias) { + row = await findArchivedRow(target.legacyAlias); + } if (!row) { throw new TRPCError({ code: 'NOT_FOUND', message: '연감 데이터를 찾을 수 없습니다.' }); } const map = asRecord(row.map) as BaseMapResult; const nations = parseYearbookNations(row.nations); - const archivedLogs = - isCurrentProfile && row.sourceId === 0 - ? await buildLogs(ctx, input.year, input.month) - : { - globalHistory: parseTextArray(row.globalHistory), - globalAction: parseTextArray(row.globalAction), - }; - const { globalHistory, globalAction } = archivedLogs; + const globalHistory = normalizeArchivedLogs(row.globalHistory, input.month); + const globalAction = normalizeArchivedLogs(row.globalAction, input.month); const data = { year: input.year, month: input.month, diff --git a/app/game-api/test/yearbookArchiveRouter.test.ts b/app/game-api/test/yearbookArchiveRouter.test.ts index 9ff6359..3e19472 100644 --- a/app/game-api/test/yearbookArchiveRouter.test.ts +++ b/app/game-api/test/yearbookArchiveRouter.test.ts @@ -17,6 +17,7 @@ const profile: GameProfile = { }; const archiveServerId = 'hwe_260725_archive'; +const currentServerId = 'che_260731_runtime'; const archiveRows = [ { id: 1, @@ -44,6 +45,19 @@ const archiveRows = [ hash: 'archive-2', createdAt: new Date('2026-07-25T01:00:00.000Z'), }, + { + id: 3, + profileName: currentServerId, + sourceId: 0, + year: 219, + month: 12, + map: { year: 219, month: 12, startYear: 190, cityList: [], nationList: [] }, + nations: [{ id: 1, name: '현재기수국', color: '#00FF00', level: 7, power: 1300, cities: ['낙양'] }], + globalHistory: ['저장된 현재 기수 과거 기록'], + globalAction: ['저장된 현재 기수 과거 행동'], + hash: 'current-archive', + createdAt: new Date('2026-07-31T00:00:00.000Z'), + }, ]; const authFor = (userId: string): GameSessionTokenPayload => ({ @@ -68,7 +82,7 @@ const buildContext = (auth: GameSessionTokenPayload | null, options: { hasGenera options.hasGeneral === false ? null : { id: where.userId === 'owner-a' ? 1 : 2, userId: where.userId }, }, worldState: { - findFirst: async () => ({ currentYear: 220, currentMonth: 1 }), + findFirst: async () => ({ currentYear: 220, currentMonth: 1, meta: { serverId: currentServerId } }), }, yearbookHistory: { findFirst: async (args: { @@ -165,4 +179,40 @@ describe('historical yearbook access from dynasty', () => { message: '연감 범위를 찾을 수 없습니다.', }); }); + + it('treats the canonical server ID and profile alias as the same live generation', async () => { + const caller = appRouter.createCaller(buildContext(authFor('owner-a'))); + + const [canonical, alias, omitted] = await Promise.all([ + caller.yearbook.getRange({ serverID: currentServerId }), + caller.yearbook.getRange({ serverID: profile.name }), + caller.yearbook.getRange(), + ]); + + expect(canonical).toEqual(alias); + expect(alias).toEqual(omitted); + expect(canonical).toEqual({ + firstYearMonth: 219 * 12 + 11, + lastYearMonth: 219 * 12 + 11, + currentYearMonth: 220 * 12, + }); + }); + + it('uses stored logs for a past month of the current generation', async () => { + const caller = appRouter.createCaller(buildContext(authFor('owner-a'))); + + const result = await caller.yearbook.getHistory({ + serverID: currentServerId, + year: 219, + month: 12, + }); + + expect(result).toMatchObject({ + notModified: false, + data: { + globalHistory: ['저장된 현재 기수 과거 기록'], + globalAction: ['저장된 현재 기수 과거 행동'], + }, + }); + }); }); diff --git a/tools/frontend-legacy-parity/visual-parity.spec.ts b/tools/frontend-legacy-parity/visual-parity.spec.ts index d4bf6a9..280387e 100644 --- a/tools/frontend-legacy-parity/visual-parity.spec.ts +++ b/tools/frontend-legacy-parity/visual-parity.spec.ts @@ -259,6 +259,7 @@ const installAuthenticatedGameFixture = async (page: Page): Promise => { ); await page.route('**/che/api/trpc/**', async (route) => { await fulfillOperations(route, (operation) => { + if (operation === 'auth.status') return { userId: 'frontend-legacy-fixture-user' }; if (operation === 'lobby.info') { return { ...fixture.game.lobby, myGeneral: fixture.game.session.general }; }