diff --git a/app/game-api/src/router/archive/index.ts b/app/game-api/src/router/archive/index.ts index fe8c14cd..79a385b2 100644 --- a/app/game-api/src/router/archive/index.ts +++ b/app/game-api/src/router/archive/index.ts @@ -19,11 +19,13 @@ import { findLegacyEmperors, findLegacyGeneral, findLegacyGeneralBattleResult, + findLegacyGeneralHallRows, findLegacyGeneralsByOwner, findLegacyGames, findLegacyNations, LEGACY_ARCHIVE_PROFILES, type LegacyArchiveProfile, + type LegacyGeneralHallRow, } from '../../services/legacyArchiveStore.js'; import { readOnlyAuthedProcedure, router } from '../../trpc.js'; import { loadTraitNames } from '../nation/shared.js'; @@ -53,6 +55,10 @@ const zPastPlayDetailInput = z.object({ generalNo: z.number().int().positive(), }); +const zPastPlaysInput = z.object({ + sourceProfile: z.enum(LEGACY_ARCHIVE_PROFILES), +}); + type ArchiveSource = 'current' | 'legacy'; interface GeneralArchiveEntry { @@ -75,6 +81,42 @@ interface ArchiveNationEntry { data: Record; } +export interface LegacyHallBattleSummary { + available: boolean; + semantics: 'independent-records'; + strategies: number | null; + warnum: number | null; + wins: number | null; + winRate: number | null; + occupied: number | null; + killCrew: number | null; + killRate: number | null; + killCrewPerson: number | null; + killRatePerson: number | null; +} + +const legacyHallBattleSummary = (rows: LegacyGeneralHallRow[]): LegacyHallBattleSummary => { + const values = new Map(rows.map((row) => [row.type, row.value])); + const value = (type: LegacyGeneralHallRow['type']): number | null => values.get(type) ?? null; + const percent = (type: LegacyGeneralHallRow['type']): number | null => { + const raw = value(type); + return raw === null ? null : raw * 100; + }; + return { + available: rows.length > 0, + semantics: 'independent-records', + strategies: value('firenum'), + warnum: value('warnum'), + wins: value('killnum'), + winRate: percent('winrate'), + occupied: value('occupied'), + killCrew: value('killcrew'), + killRate: percent('killrate'), + killCrewPerson: value('killcrew_person'), + killRatePerson: percent('killrate_person'), + }; +}; + const key = (source: ArchiveSource, sourceProfile: string, serverId: string): string => `${source}:${sourceProfile}:${serverId}`; @@ -190,16 +232,18 @@ const buildGeneralDetail = async (entry: GeneralArchiveEntry, nation: ReturnType }; export const archiveRouter = router({ - myPastPlays: readOnlyAuthedProcedure.query(async ({ ctx }) => { + myPastPlays: readOnlyAuthedProcedure.input(zPastPlaysInput).query(async ({ ctx, input }) => { const owner = ctx.auth?.user.id; if (!owner) throw new Error('Authenticated archive query is missing its user identity'); const [legacyRows, currentRows] = await Promise.all([ - findLegacyGeneralsByOwner(ctx.db, owner), - ctx.db.oldGeneral.findMany({ - where: { owner }, - orderBy: [{ lastYearMonth: 'desc' }, { serverId: 'desc' }, { generalNo: 'asc' }], - }), + 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' }], + }) + : [], ]); const legacyIdentity = new Set( legacyRows.map((row) => `${row.sourceProfile}:${row.serverId}:${row.generalNo}`) @@ -454,6 +498,7 @@ export const archiveRouter = router({ let dynastyId: number | null = null; let battleResultContent: string | null = null; 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: '지원하지 않는 이전 서버 프로필입니다.' }); @@ -477,7 +522,7 @@ export const archiveRouter = router({ snapshot: canonicalSnapshot(row.data, row.name), }; const keyInput = [{ sourceProfile: profile, serverId: input.serverId }]; - const [nations, emperors, battleResult] = await Promise.all([ + const [nations, emperors, battleResult, hallRows] = await Promise.all([ findLegacyNations(ctx.db, keyInput), findLegacyEmperors(ctx.db, keyInput), findLegacyGeneralBattleResult(ctx.db, { @@ -485,6 +530,11 @@ export const archiveRouter = router({ serverId: input.serverId, generalNo: input.generalNo, }), + findLegacyGeneralHallRows(ctx.db, { + sourceProfile: profile, + serverId: input.serverId, + generalNo: input.generalNo, + }), ]); nationRows = nations.map((nation) => ({ source: 'legacy', @@ -497,6 +547,7 @@ export const archiveRouter = router({ dynastyId = Number(emperors[0]?.id ?? 0) || null; battleResultContent = battleResult?.content ?? null; battleResultAvailable = battleResult !== null; + hallBattle = legacyHallBattleSummary(hallRows); } } else { const row = await ctx.db.oldGeneral.findFirst({ @@ -580,6 +631,7 @@ export const archiveRouter = router({ killRate: snapshot.battle.killRate, recentWar: snapshot.battle.recentWar, }, + hallBattle, logs, }; }), diff --git a/app/game-api/src/services/legacyArchiveStore.ts b/app/game-api/src/services/legacyArchiveStore.ts index f9b2d062..df19a98c 100644 --- a/app/game-api/src/services/legacyArchiveStore.ts +++ b/app/game-api/src/services/legacyArchiveStore.ts @@ -42,6 +42,25 @@ export interface LegacyGeneralBattleResultRow { contentHash: string; } +export const LEGACY_GENERAL_HALL_TYPES = [ + 'firenum', + 'warnum', + 'killnum', + 'winrate', + 'occupied', + 'killcrew', + 'killrate', + 'killcrew_person', + 'killrate_person', +] as const; + +export type LegacyGeneralHallType = (typeof LEGACY_GENERAL_HALL_TYPES)[number]; + +export interface LegacyGeneralHallRow { + type: LegacyGeneralHallType; + value: number; +} + export interface LegacyNationRow { sourceProfile: LegacyArchiveProfile; legacyId: number; @@ -79,7 +98,7 @@ export interface LegacyHallRow { export const findLegacyGeneralsByOwner = async ( db: LegacyArchiveDatabase, - owner: string + input: { owner: string; sourceProfile: LegacyArchiveProfile } ): Promise => db.$queryRaw(GamePrisma.sql` SELECT @@ -95,8 +114,9 @@ export const findLegacyGeneralsByOwner = async ( "source_format" AS "sourceFormat", "data" FROM "legacy_archive"."general" - WHERE "owner" = ${owner} - ORDER BY "last_yearmonth" DESC, "source_profile", "server_id" DESC, "general_no" + WHERE "owner" = ${input.owner} + AND "source_profile" = ${input.sourceProfile} + ORDER BY "last_yearmonth" DESC, "server_id" DESC, "general_no" `); export const findLegacyGeneral = async ( @@ -144,6 +164,22 @@ export const findLegacyGeneralBattleResult = async ( return rows[0] ?? null; }; +export const findLegacyGeneralHallRows = async ( + db: LegacyArchiveDatabase, + input: { sourceProfile: LegacyArchiveProfile; serverId: string; generalNo: number } +): Promise => + db.$queryRaw(GamePrisma.sql` + SELECT + "type", + "value" + FROM "legacy_archive"."hall" + WHERE "source_profile" = ${input.sourceProfile} + AND "server_id" = ${input.serverId} + AND "general_no" = ${input.generalNo} + AND "type" IN (${GamePrisma.join(LEGACY_GENERAL_HALL_TYPES)}) + ORDER BY "type" + `); + export const findLegacyGeneralsForServer = async ( db: LegacyArchiveDatabase, input: { sourceProfile: LegacyArchiveProfile; serverId: string; generalNos: number[] } diff --git a/app/game-api/test/archiveRouter.test.ts b/app/game-api/test/archiveRouter.test.ts index 13a1261c..8a850139 100644 --- a/app/game-api/test/archiveRouter.test.ts +++ b/app/game-api/test/archiveRouter.test.ts @@ -31,9 +31,22 @@ const context = ( includeCancellation = false ): GameApiContext => { const db = { - $queryRaw: async (query: { strings?: readonly string[] }) => { + $queryRaw: async (query: { strings?: readonly string[]; values?: readonly unknown[] }) => { if (!includeLegacy) return []; const sql = query.strings?.join(' ') ?? ''; + if (sql.includes('legacy_archive"."hall')) { + return [ + { type: 'firenum', value: 4 }, + { type: 'warnum', value: 16 }, + { type: 'killnum', value: 10 }, + { type: 'winrate', value: 0.625 }, + { type: 'occupied', value: 3 }, + { type: 'killcrew', value: 12_000 }, + { type: 'killrate', value: 0.75 }, + { type: 'killcrew_person', value: 9_000 }, + { type: 'killrate_person', value: 0.5 }, + ]; + } if (sql.includes('legacy_archive"."general_battle_result')) { return [ { @@ -44,6 +57,7 @@ const context = ( ]; } if (sql.includes('legacy_archive"."general')) { + if (!query.values?.includes('hwe')) return []; return [ { sourceProfile: 'hwe', @@ -300,10 +314,12 @@ const context = ( describe('archive.myPastPlays', () => { it('requires authentication and returns only the authenticated owner archive', async () => { - await expect(appRouter.createCaller(context(null)).archive.myPastPlays()).rejects.toMatchObject({ + await expect( + appRouter.createCaller(context(null)).archive.myPastPlays({ sourceProfile: 'che' }) + ).rejects.toMatchObject({ code: 'UNAUTHORIZED', }); - const result = await appRouter.createCaller(context(auth)).archive.myPastPlays(); + const result = await appRouter.createCaller(context(auth)).archive.myPastPlays({ sourceProfile: 'che' }); expect(result.seasons).toEqual([ expect.objectContaining({ source: 'current', @@ -381,7 +397,9 @@ 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(); + const result = await appRouter + .createCaller(context(auth, false, true)) + .archive.myPastPlays({ sourceProfile: 'che' }); expect(result.seasons).toEqual([ expect.objectContaining({ @@ -397,7 +415,7 @@ 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(); + const list = await caller.archive.myPastPlays({ sourceProfile: 'hwe' }); expect(list.seasons).toContainEqual( expect.objectContaining({ source: 'legacy', @@ -427,6 +445,19 @@ describe('archive.myPastPlays', () => { progression: expect.objectContaining({ dex: [1000, 2000, 3000, 4000, 5000] }), }), battle: expect.objectContaining({ available: true, warnum: 10, wins: 6, winRate: 60 }), + hallBattle: { + available: true, + semantics: 'independent-records', + strategies: 4, + warnum: 16, + wins: 10, + winRate: 62.5, + occupied: 3, + killCrew: 12_000, + killRate: 75, + killCrewPerson: 9_000, + killRatePerson: 50, + }, logs: expect.objectContaining({ generalHistory: { available: true, entries: [{ id: 1, text: '이전 서버 열전' }] }, battleDetail: { available: false, entries: [] }, @@ -441,4 +472,17 @@ describe('archive.myPastPlays', () => { }); expect(JSON.stringify(detail)).not.toContain('raw_data'); }); + + it('limits the archive list to the requested source profile', 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 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'); + }); }); diff --git a/app/game-frontend/e2e/pastPlays.spec.ts b/app/game-frontend/e2e/pastPlays.spec.ts index 1e3bc936..0f547799 100644 --- a/app/game-frontend/e2e/pastPlays.spec.ts +++ b/app/game-frontend/e2e/pastPlays.spec.ts @@ -9,19 +9,25 @@ 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[] = []; 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'); return response({ seasons: [ { - sourceProfile: 'che', + sourceProfile: requestedProfile ?? 'che', source: options.abandoned ? 'current' : 'legacy', serverId: 'che_2024_01', openedAt: '2024-01-31T00:00:00.000Z', @@ -109,6 +115,19 @@ const installArchive = async (page: Page, options: { battleAvailable?: boolean; killRate: 75, recentWar: '2024-01-30T03:00:00.000Z', }, + hallBattle: { + available: true, + semantics: 'independent-records', + strategies: 4, + warnum: 18, + wins: 12, + winRate: 66.67, + occupied: 3, + killCrew: 14_000, + killRate: 80, + killCrewPerson: 9_000, + killRatePerson: 60, + }, logs: { generalHistory: { available: true, @@ -136,8 +155,28 @@ const installArchive = async (page: Page, options: { battleAvailable?: boolean; }); await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(results) }); }); + return { requestedProfiles }; }; +test('과거 장수 기록을 체·퀘·풰·퉤·냐·퍄·훼 서버별로 나누어 조회한다', 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.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']); +}); + test('지난 플레이 관직은 숫자 대신 저장된 Ref 표시명으로 나타난다', async ({ page }) => { await installArchive(page); await page.goto('past-plays'); @@ -208,7 +247,7 @@ test('past plays is available without a current general and preserves desktop in await expect(page.getByRole('heading', { name: '내 지난 플레이 보기' })).toBeVisible(); await expect(page.getByText('천하쟁패 · 51기')).toBeVisible(); await expect(page.getByText('이전 서버 기록')).toBeVisible(); - await expect(page.getByText('che', { exact: true })).toBeVisible(); + await expect(page.locator('.season-identity').getByText('체', { exact: true })).toBeVisible(); await expect(page.getByText(/2024.*개장/)).toBeVisible(); await expect(page.locator('.general-name')).toHaveText('관우'); await expect(page.locator('tbody tr').filter({ hasText: '관우' })).toContainText('황제'); @@ -226,6 +265,12 @@ test('past plays is available without a current general and preserves desktop in await expect(page.locator('.archive-general-card [role="progressbar"]')).toHaveCount(14); await expect(page.locator('[data-general-battle-summary]')).toContainText('승률62.5%'); await expect(page.locator('[data-general-battle-summary]')).toContainText('살상률75.0%'); + const hallBattle = page.locator('[data-hall-battle-record]'); + await expect(hallBattle).toContainText('명예의 전당 보존 기록'); + await expect(hallBattle).toContainText('항목별 기록 시점이 서로 다를 수 있습니다.'); + await expect(hallBattle).toContainText('18'); + await expect(hallBattle).toContainText('66.67%'); + await expect(hallBattle).toContainText('대인 사살'); await expect(page.locator('[data-log-type="battleDetail"]')).toContainText( '이 기수에는 전투 기록이 보존되지 않았습니다.' ); @@ -305,10 +350,20 @@ 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, recordBottom: element.querySelector('[data-general-record-panels]')!.getBoundingClientRect().bottom, shellBottom: element.getBoundingClientRect().bottom, })); 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); expect(detailMetrics.recordBottom).toBeLessThanOrEqual(detailMetrics.shellBottom); }); diff --git a/app/game-frontend/src/views/PastPlaysView.vue b/app/game-frontend/src/views/PastPlaysView.vue index 17ed68ec..e7724901 100644 --- a/app/game-frontend/src/views/PastPlaysView.vue +++ b/app/game-frontend/src/views/PastPlaysView.vue @@ -1,6 +1,8 @@