diff --git a/app/game-api/src/router/archive/index.ts b/app/game-api/src/router/archive/index.ts index 79a385b2..1b5de0a8 100644 --- a/app/game-api/src/router/archive/index.ts +++ b/app/game-api/src/router/archive/index.ts @@ -23,7 +23,7 @@ import { findLegacyGeneralsByOwner, findLegacyGames, findLegacyNations, - LEGACY_ARCHIVE_PROFILES, + isLegacyArchiveProfile, type LegacyArchiveProfile, type LegacyGeneralHallRow, } from '../../services/legacyArchiveStore.js'; @@ -50,15 +50,10 @@ const canonicalSnapshot = (value: unknown, fallbackName: string): ArchivedGenera const zPastPlayDetailInput = z.object({ source: z.enum(['current', 'legacy']).default('current'), - sourceProfile: z.enum(LEGACY_ARCHIVE_PROFILES).optional(), serverId: z.string().trim().min(1).max(64), generalNo: z.number().int().positive(), }); -const zPastPlaysInput = z.object({ - sourceProfile: z.enum(LEGACY_ARCHIVE_PROFILES), -}); - type ArchiveSource = 'current' | 'legacy'; interface GeneralArchiveEntry { @@ -232,18 +227,17 @@ const buildGeneralDetail = async (entry: GeneralArchiveEntry, nation: ReturnType }; export const archiveRouter = router({ - myPastPlays: readOnlyAuthedProcedure.input(zPastPlaysInput).query(async ({ ctx, input }) => { + myPastPlays: readOnlyAuthedProcedure.query(async ({ ctx }) => { const owner = ctx.auth?.user.id; if (!owner) throw new Error('Authenticated archive query is missing its user identity'); + const legacyProfile = isLegacyArchiveProfile(ctx.profile.id) ? ctx.profile.id : null; const [legacyRows, currentRows] = await Promise.all([ - findLegacyGeneralsByOwner(ctx.db, { owner, sourceProfile: input.sourceProfile }), - input.sourceProfile === ctx.profile.id - ? ctx.db.oldGeneral.findMany({ - where: { owner }, - orderBy: [{ lastYearMonth: 'desc' }, { serverId: 'desc' }, { generalNo: 'asc' }], - }) - : [], + legacyProfile ? findLegacyGeneralsByOwner(ctx.db, { owner, sourceProfile: legacyProfile }) : [], + ctx.db.oldGeneral.findMany({ + where: { owner }, + orderBy: [{ lastYearMonth: 'desc' }, { serverId: 'desc' }, { generalNo: 'asc' }], + }), ]); const legacyIdentity = new Set( legacyRows.map((row) => `${row.sourceProfile}:${row.serverId}:${row.generalNo}`) @@ -488,10 +482,7 @@ export const archiveRouter = router({ myPastPlayDetail: readOnlyAuthedProcedure.input(zPastPlayDetailInput).query(async ({ ctx, input }) => { const owner = ctx.auth?.user.id; if (!owner) throw new Error('Authenticated archive query is missing its user identity'); - const sourceProfile = input.sourceProfile ?? ctx.profile.id; - if (input.source === 'current' && sourceProfile !== ctx.profile.id) { - throw new TRPCError({ code: 'NOT_FOUND', message: '지난 장수 기록을 찾을 수 없습니다.' }); - } + const sourceProfile = ctx.profile.id; let entry: GeneralArchiveEntry | null = null; let nationRows: ArchiveNationEntry[] = []; @@ -500,10 +491,10 @@ export const archiveRouter = router({ let battleResultAvailable = false; let hallBattle = legacyHallBattleSummary([]); if (input.source === 'legacy') { - if (!LEGACY_ARCHIVE_PROFILES.includes(sourceProfile as LegacyArchiveProfile)) { - throw new TRPCError({ code: 'BAD_REQUEST', message: '지원하지 않는 이전 서버 프로필입니다.' }); + if (!isLegacyArchiveProfile(sourceProfile)) { + throw new TRPCError({ code: 'NOT_FOUND', message: '지난 장수 기록을 찾을 수 없습니다.' }); } - const profile = sourceProfile as LegacyArchiveProfile; + const profile: LegacyArchiveProfile = sourceProfile; const row = await findLegacyGeneral(ctx.db, { owner, sourceProfile: profile, diff --git a/app/game-api/test/archiveRouter.test.ts b/app/game-api/test/archiveRouter.test.ts index 8a850139..443aeb19 100644 --- a/app/game-api/test/archiveRouter.test.ts +++ b/app/game-api/test/archiveRouter.test.ts @@ -57,11 +57,11 @@ const context = ( ]; } if (sql.includes('legacy_archive"."general')) { - if (!query.values?.includes('hwe')) return []; + if (!query.values?.includes('che')) return []; return [ { - sourceProfile: 'hwe', - serverId: 'hwe_archive_1', + sourceProfile: 'che', + serverId: 'che_archive_1', generalNo: 21, legacyId: 21, owner: 'user-1', @@ -146,8 +146,8 @@ const context = ( if (sql.includes('legacy_archive"."game_history')) { return [ { - sourceProfile: 'hwe', - serverId: 'hwe_archive_1', + sourceProfile: 'che', + serverId: 'che_archive_1', legacyId: 1, openedAt: new Date('2019-09-21T00:00:00.000Z'), completedAt: null, @@ -164,9 +164,9 @@ const context = ( if (sql.includes('legacy_archive"."nation')) { return [ { - sourceProfile: 'hwe', + sourceProfile: 'che', legacyId: 1, - serverId: 'hwe_archive_1', + serverId: 'che_archive_1', nation: 2, data: { name: '이전국', color: '#0000ff', level: 7 }, archivedAt: new Date('2020-01-02T00:00:00.000Z'), @@ -174,7 +174,7 @@ const context = ( ]; } if (sql.includes('legacy_archive"."emperor')) { - return [{ id: 99n, sourceProfile: 'hwe', legacyId: 1, serverId: 'hwe_archive_1', data: {} }]; + return [{ id: 99n, sourceProfile: 'che', legacyId: 1, serverId: 'che_archive_1', data: {} }]; } return []; }, @@ -314,12 +314,10 @@ const context = ( describe('archive.myPastPlays', () => { it('requires authentication and returns only the authenticated owner archive', async () => { - await expect( - appRouter.createCaller(context(null)).archive.myPastPlays({ sourceProfile: 'che' }) - ).rejects.toMatchObject({ + await expect(appRouter.createCaller(context(null)).archive.myPastPlays()).rejects.toMatchObject({ code: 'UNAUTHORIZED', }); - const result = await appRouter.createCaller(context(auth)).archive.myPastPlays({ sourceProfile: 'che' }); + const result = await appRouter.createCaller(context(auth)).archive.myPastPlays(); expect(result.seasons).toEqual([ expect.objectContaining({ source: 'current', @@ -397,9 +395,7 @@ describe('archive.myPastPlays', () => { }); it('labels a retained cancellation as an unnumbered abandoned game without a dynasty link', async () => { - const result = await appRouter - .createCaller(context(auth, false, true)) - .archive.myPastPlays({ sourceProfile: 'che' }); + const result = await appRouter.createCaller(context(auth, false, true)).archive.myPastPlays(); expect(result.seasons).toEqual([ expect.objectContaining({ @@ -415,12 +411,12 @@ describe('archive.myPastPlays', () => { it('returns normalized previous-server detail from the dedicated archive without exposing raw data', async () => { const caller = appRouter.createCaller(context(auth, true)); - const list = await caller.archive.myPastPlays({ sourceProfile: 'hwe' }); + const list = await caller.archive.myPastPlays(); expect(list.seasons).toContainEqual( expect.objectContaining({ source: 'legacy', - sourceProfile: 'hwe', - serverId: 'hwe_archive_1', + sourceProfile: 'che', + serverId: 'che_archive_1', openedAt: '2019-09-21T00:00:00.000Z', dynastyId: 99, generals: [expect.objectContaining({ name: '이전서버장수', nationName: '이전국' })], @@ -429,13 +425,12 @@ describe('archive.myPastPlays', () => { const detail = await caller.archive.myPastPlayDetail({ source: 'legacy', - sourceProfile: 'hwe', - serverId: 'hwe_archive_1', + serverId: 'che_archive_1', generalNo: 21, }); expect(detail).toMatchObject({ source: 'legacy', - sourceProfile: 'hwe', + sourceProfile: 'che', dynastyPath: '/dynasty/99?source=legacy', nation: { id: 2, name: '이전국', color: '#0000ff' }, general: expect.objectContaining({ @@ -473,16 +468,18 @@ describe('archive.myPastPlays', () => { expect(JSON.stringify(detail)).not.toContain('raw_data'); }); - it('limits the archive list to the requested source profile', async () => { + it('combines only the current profile Core and PHP archives without a profile selector', async () => { const caller = appRouter.createCaller(context(auth, true)); - const che = await caller.archive.myPastPlays({ sourceProfile: 'che' }); - expect(che.seasons).toHaveLength(1); - expect(che.seasons[0]?.sourceProfile).toBe('che'); + const result = await caller.archive.myPastPlays(); + expect(result.seasons).toHaveLength(2); + expect(result.seasons.map((season) => season.sourceProfile)).toEqual(['che', 'che']); + expect(result.seasons.map((season) => season.source).sort()).toEqual(['current', 'legacy']); - const hwe = await caller.archive.myPastPlays({ sourceProfile: 'hwe' }); - expect(hwe.seasons).toHaveLength(1); - expect(hwe.seasons[0]?.sourceProfile).toBe('hwe'); - expect(hwe.seasons[0]?.source).toBe('legacy'); + const queryWithInjectedProfile = caller.archive.myPastPlays as unknown as (input: { + sourceProfile: string; + }) => ReturnType; + const attemptedCrossProfile = await queryWithInjectedProfile({ sourceProfile: 'hwe' }); + expect(attemptedCrossProfile.seasons.map((season) => season.sourceProfile)).toEqual(['che', 'che']); }); }); diff --git a/app/game-frontend/e2e/pastPlays.spec.ts b/app/game-frontend/e2e/pastPlays.spec.ts index 0f547799..aeae5744 100644 --- a/app/game-frontend/e2e/pastPlays.spec.ts +++ b/app/game-frontend/e2e/pastPlays.spec.ts @@ -5,29 +5,27 @@ import { expect, test, type Page, type Route } from '@playwright/test'; import { gamePath, gameProfile, gameTrpcRoute } from './gameTestPaths.js'; const response = (data: unknown) => ({ result: { data } }); +const gameProfileId = gameProfile.split(':', 1)[0] ?? 'che'; const operationNames = (route: Route) => decodeURIComponent(new URL(route.request().url()).pathname.split('/trpc/')[1] ?? '').split(','); const installArchive = async (page: Page, options: { battleAvailable?: boolean; abandoned?: boolean } = {}) => { - const requestedProfiles: string[] = []; + const archiveRequestBodies: string[] = []; await page.addInitScript((profile) => { localStorage.setItem('sammo-game-token', 'ga_archive'); localStorage.setItem('sammo-game-profile', profile); }, gameProfile); await page.route(gameTrpcRoute, async (route) => { const requestBody = route.request().postData() ?? ''; - const requestedProfile = ['che', 'kwe', 'pwe', 'twe', 'nya', 'pya', 'hwe'].find((profile) => - requestBody.includes(`"sourceProfile":"${profile}"`) - ); const results = operationNames(route).map((operation) => { if (operation === 'auth.status') return response({ ok: true }); if (operation === 'lobby.info') return response({ myGeneral: null }); if (operation === 'archive.myPastPlays') { - requestedProfiles.push(requestedProfile ?? 'missing'); + archiveRequestBodies.push(requestBody); return response({ seasons: [ { - sourceProfile: requestedProfile ?? 'che', + sourceProfile: gameProfileId, source: options.abandoned ? 'current' : 'legacy', serverId: 'che_2024_01', openedAt: '2024-01-31T00:00:00.000Z', @@ -155,26 +153,17 @@ const installArchive = async (page: Page, options: { battleAvailable?: boolean; }); await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(results) }); }); - return { requestedProfiles }; + return { archiveRequestBodies }; }; -test('과거 장수 기록을 체·퀘·풰·퉤·냐·퍄·훼 서버별로 나누어 조회한다', async ({ page }) => { +test('현재 게임 profile 기록만 조회하고 교차 profile 선택기를 노출하지 않는다', async ({ page }) => { const state = await installArchive(page); await page.goto('past-plays'); - const tabs = page.getByRole('navigation', { name: '과거 장수 서버 선택' }); - await expect(tabs.getByRole('button')).toHaveCount(7); - await expect(tabs.getByRole('button')).toHaveText(['체', '퀘', '풰', '퉤', '냐', '퍄', '훼']); - await expect(tabs.getByRole('button', { name: '체 서버' })).toHaveAttribute('aria-pressed', 'true'); - await expect(tabs.getByRole('button', { name: '체 서버' })).toHaveCSS('color', 'rgb(135, 206, 235)'); + await expect(page.getByRole('navigation', { name: '과거 장수 서버 선택' })).toHaveCount(0); await expect(page.locator('.season-identity').getByText('체', { exact: true })).toBeVisible(); - - await tabs.getByRole('button', { name: '훼 서버' }).hover(); - await expect(tabs.getByRole('button', { name: '훼 서버' })).toHaveCSS('color', 'rgb(135, 206, 235)'); - await tabs.getByRole('button', { name: '훼 서버' }).click(); - await expect(tabs.getByRole('button', { name: '훼 서버' })).toHaveAttribute('aria-pressed', 'true'); - await expect(page.locator('.season-identity').getByText('훼', { exact: true })).toBeVisible(); - expect(state.requestedProfiles).toEqual(['che', 'hwe']); + expect(state.archiveRequestBodies).toHaveLength(1); + expect(state.archiveRequestBodies[0]).not.toContain('sourceProfile'); }); test('지난 플레이 관직은 숫자 대신 저장된 Ref 표시명으로 나타난다', async ({ page }) => { @@ -350,8 +339,6 @@ test('past plays keeps the legacy-width table scrollable on a mobile viewport', const detailMetrics = await page.locator('.detail-shell').evaluate((element) => ({ width: element.getBoundingClientRect().width, scrollWidth: element.scrollWidth, - profileTabsWidth: document.querySelector('.profile-tabs')!.getBoundingClientRect().width, - profileTabsScrollWidth: document.querySelector('.profile-tabs')!.scrollWidth, hallRecordWidth: element.querySelector('[data-hall-battle-record]')!.getBoundingClientRect().width, hallRecordScrollWidth: element.querySelector('[data-hall-battle-record]')!.scrollWidth, hallColumns: getComputedStyle(element.querySelector('[data-hall-battle-record] dl')!).gridTemplateColumns, @@ -360,8 +347,6 @@ test('past plays keeps the legacy-width table scrollable on a mobile viewport', })); expect(detailMetrics.width).toBe(498); expect(detailMetrics.scrollWidth).toBe(498); - expect(detailMetrics.profileTabsWidth).toBe(500); - expect(detailMetrics.profileTabsScrollWidth).toBeLessThanOrEqual(detailMetrics.profileTabsWidth); expect(detailMetrics.hallRecordWidth).toBeGreaterThan(0); expect(detailMetrics.hallRecordScrollWidth).toBeLessThanOrEqual(detailMetrics.hallRecordWidth); expect(detailMetrics.hallColumns.split(' ')).toHaveLength(3); diff --git a/app/game-frontend/src/views/PastPlaysView.vue b/app/game-frontend/src/views/PastPlaysView.vue index e7724901..58a36570 100644 --- a/app/game-frontend/src/views/PastPlaysView.vue +++ b/app/game-frontend/src/views/PastPlaysView.vue @@ -1,7 +1,7 @@