diff --git a/app/game-api/src/router/playAudit/index.ts b/app/game-api/src/router/playAudit/index.ts index deab726c..75a044f9 100644 --- a/app/game-api/src/router/playAudit/index.ts +++ b/app/game-api/src/router/playAudit/index.ts @@ -22,6 +22,38 @@ import { export const playAuditRouter = router({ nationSeries, + nations: auditProcedure.input(zAuditPage.omit({ nationId: true })).query(({ ctx, input }) => + readAudit(ctx, async (tx) => { + const world = await readAuditWorld(tx); + const identity = z.object({ id: z.number(), name: z.string(), color: z.string() }); + if (input.at) { + const sample = await findAuditMonth(tx, world, input.at); + const rows = sample + ? await tx.playAuditNation.findMany({ + where: { + sampleId: sample.id, + nationId: input.cursor === undefined ? undefined : { gt: input.cursor }, + }, + orderBy: { nationId: 'asc' }, + take: input.limit + 1, + select: { data: true }, + }) + : []; + return { + ...world, + collected: Boolean(sample), + ...pageResult(rows.map((row) => identity.parse(row.data)), input.limit, (row) => row.id), + }; + } + const rows = await tx.nation.findMany({ + where: { id: input.cursor === undefined ? undefined : { gt: input.cursor } }, + orderBy: { id: 'asc' }, + take: input.limit + 1, + select: { id: true, name: true, color: true }, + }); + return { ...world, collected: true, ...pageResult(rows, input.limit, (row) => row.id) }; + }) + ), capabilities: auditProcedure.query(({ ctx }) => ({ profileName: ctx.profile.name, read: true, diff --git a/app/game-api/test/securityTransport.integration.test.ts b/app/game-api/test/securityTransport.integration.test.ts index 7c436b57..4db4abb6 100644 --- a/app/game-api/test/securityTransport.integration.test.ts +++ b/app/game-api/test/securityTransport.integration.test.ts @@ -2171,6 +2171,12 @@ integration('game API security over HTTP transport', () => { return { status: response.status, body: (await response.json()) as unknown }; }; try { + await db.nation.createMany({ + data: [ + { id: 99121, name: '현재감사국가1', color: '#ffffff' }, + { id: 99122, name: '현재감사국가2', color: '#ffffff' }, + ], + }); await db.worldState.update({ where: { id: fixtureWorldId }, data: { @@ -2284,6 +2290,24 @@ integration('game API security over HTTP transport', () => { })), }); await db.worldState.update({ where: { id: fixtureWorldId }, data: { currentMonth: 7 } }); + expect((await get('nations', admin, { at: { year: 190, month: 1 }, limit: 1 })).body).toMatchObject({ + result: { data: { collected: true, items: [{ id: ownerNationId, name: '국가1' }] } }, + }); + expect((await get('nations', admin, { at: { year: 190, month: 7 } })).body).toMatchObject({ + result: { data: { collected: false, items: [] } }, + }); + expect((await get('nations', admin, { limit: 201 })).status).toBe(400); + expect((await get('nations')).status).toBe(401); + expect((await get('nations', await token(['admin']))).status).toBe(403); + expect((await get('nations', admin, { limit: 1 })).body).toMatchObject({ + result: { + data: { + collected: true, + nextCursor: expect.any(Number), + items: [expect.objectContaining({ id: expect.any(Number), name: expect.any(String) })], + }, + }, + }); const series = await get('nationSeries', admin, { nationId: ownerNationId, from: { year: 190, month: 1 }, @@ -2355,6 +2379,7 @@ integration('game API security over HTTP transport', () => { await expect.poll(async () => (await get('capabilities', admin)).status).toBe(401); } finally { await db.playAuditMonth.deleteMany({ where: { serverId: seasonId } }); + await db.nation.deleteMany({ where: { id: { in: [99121, 99122] } } }); await db.worldState.update({ where: { id: fixtureWorldId }, data: { diff --git a/app/game-frontend/e2e/playAudit.spec.ts b/app/game-frontend/e2e/playAudit.spec.ts new file mode 100644 index 00000000..d9243a8b --- /dev/null +++ b/app/game-frontend/e2e/playAudit.spec.ts @@ -0,0 +1,285 @@ +import { mkdir, writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { expect, test, type Page } from '@playwright/test'; +import { gamePath, gameProfile, gameTrpcRoute } from './gameTestPaths.js'; + +const world = { + year: 190, + month: 7, + startYear: 190, + serverId: 'audit-fixture', + tick: '100', + asOf: '2026-09-16T00:00:00.000Z', +}; +const dex = { dex1: 100, dex2: 200, dex3: 300, dex4: 400, dex5: 500 }; +const population = { count: 2, gold: 200, rice: 400, dex, averageGold: 100, averageRice: 200, averageDex: dex }; +const general = { + id: 1, + name: '감사장수', + userId: 'fixture', + nationId: 2, + cityId: 3, + troopId: 0, + npcState: 2, + gold: 1200, + rice: 2400, + stats: { leadership: 80, strength: 70, intelligence: 90 }, + experience: 100, + dedication: 200, + officerLevel: 2, + injury: 0, + age: 30, + crew: 5000, + crewTypeId: 1, + train: 80, + atmos: 90, + dex, + role: { + personality: null, + specialDomestic: null, + specialWar: null, + items: { horse: null, weapon: null, book: null, item: null }, + }, +}; +const install = async (page: Page, denied = false) => { + const requests: { operation: string; input: Record }[] = []; + await page.addInitScript((profile) => { + localStorage.setItem('sammo-game-token', 'ga_audit'); + localStorage.setItem('sammo-game-profile', profile); + }, gameProfile); + await page.route(gameTrpcRoute, async (route) => { + const url = new URL(route.request().url()); + const inputs = JSON.parse(url.searchParams.get('input') ?? route.request().postData() ?? '{}'); + const results = decodeURIComponent(url.pathname.split('/trpc/')[1] ?? '') + .split(',') + .map((operation, index) => { + const input = inputs[index] ?? {}; + requests.push({ operation, input }); + const result = (data: unknown) => ({ result: { data } }); + switch (operation) { + case 'auth.status': + return result({ ok: true }); + case 'lobby.info': + return result({ myGeneral: null }); + case 'playAudit.capabilities': + return denied + ? { + error: { + message: '이 프로필의 플레이 감사 권한이 필요합니다.', + code: -32003, + data: { code: 'FORBIDDEN', httpStatus: 403 }, + }, + } + : result({ profileName: gameProfile, read: true, accounts: false }); + case 'playAudit.coverage': + return result({ ...world, status: 'COLLECTED', samples: [], nextCursor: null }); + case 'playAudit.nations': + return result({ + ...world, + collected: true, + items: [{ id: 2, name: '촉', color: '#ff0000' }], + nextCursor: null, + }); + case 'playAudit.nationSeries': + return result({ + ...world, + nextCursor: null, + items: [ + { + year: 190, + month: 1, + periodMonths: 6, + complete: true, + from: { year: 190, month: 1 }, + to: { year: 190, month: 6 }, + stockAsOf: { year: 190, month: 6 }, + stock: { + id: 2, + name: '촉', + color: '#ff0000', + gold: 600, + rice: 1200, + tech: 100, + appliedRate: 20, + populations: { human: population, npc: population, troopNpc: population }, + }, + flows: { incomeGold: 21, incomeRice: null, paidGold: 10, paidRice: 0 }, + months: [1, 2, 3, 4, 5, 6].map((month) => ({ + year: 190, + month, + collected: true, + nationPresent: true, + settlementsComplete: true, + })), + }, + ], + }); + case 'playAudit.generals': + return result({ + ...world, + collected: !input.at || (input.at as { month: number }).month !== 7, + sample: input.at ?? null, + nextCursor: input.cursor ? null : 1, + items: + input.at && (input.at as { month: number }).month === 7 + ? [] + : [ + { + ...general, + id: input.cursor ? 2 : 1, + name: input.cursor ? '다음장수' : '감사장수', + }, + ], + }); + case 'playAudit.cities': + return result({ + ...world, + collected: true, + sample: null, + nextCursor: null, + items: [ + { + id: 3, + name: '성도', + nationId: 2, + level: 4, + state: 0, + population: 10000, + populationMax: 20000, + agriculture: 100, + agricultureMax: 200, + commerce: 100, + commerceMax: 200, + security: 100, + securityMax: 200, + wall: 100, + wallMax: 200, + defence: 100, + defenceMax: 200, + supplyState: 1, + frontState: 0, + trust: 80, + }, + ], + }); + default: + throw new Error(`Unexpected operation ${operation}`); + } + }); + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(results) }); + }); + return requests; +}; + +const capture = async (page: Page, name: string) => { + await page.evaluate(() => document.fonts.ready); + const directory = resolve('/tmp/play-audit-browser', gameProfile.replace(':', '-')); + await mkdir(directory, { recursive: true }); + const geometry = await page.evaluate(() => ({ + viewport: { width: innerWidth, height: innerHeight, dpr: devicePixelRatio }, + width: document.documentElement.scrollWidth, + nodes: [...document.querySelectorAll('.audit-page, .panel-card, select, input, button, table')].map((node) => { + const rect = node.getBoundingClientRect(); + const style = getComputedStyle(node); + return { + tag: node.tagName, + width: rect.width, + height: rect.height, + x: rect.x, + y: rect.y, + font: style.fontSize, + background: style.backgroundColor, + }; + }), + })); + expect(geometry.width).toBeLessThanOrEqual(geometry.viewport.width); + await writeFile(resolve(directory, `${name}.json`), JSON.stringify(geometry, null, 2)); + await writeFile(resolve(directory, `${name}.html`), await page.content()); + await page.screenshot({ path: resolve(directory, `${name}.png`), fullPage: true }); +}; + +test('profile audit without a general: chart controls, lazy reads, direct reload', async ({ page }) => { + const requests = await install(page); + await page.goto(gamePath('/play-audit?tab=nations&nation=2&fromYear=190&fromMonth=1&year=190&month=6')); + await expect(page.getByRole('cell', { name: '600', exact: true })).toBeVisible(); + expect(requests.some((request) => ['playAudit.generals', 'playAudit.cities'].includes(request.operation))).toBe( + false + ); + const count = requests.length; + await page.getByLabel('지표', { exact: true }).selectOption('incomeGold'); + await expect(page.getByRole('cell', { name: '21', exact: true })).toBeVisible(); + await page.getByLabel('지표', { exact: true }).selectOption('incomeRice'); + await expect(page.getByRole('cell', { name: '자료 없음', exact: true })).toBeVisible(); + expect(requests.length).toBe(count); + await page.getByText('월별 표본 수집됨', { exact: true }).click(); + await expect(page.getByText('190년 1월: 수집됨')).toBeVisible(); + await capture(page, 'desktop-series'); + await page.reload(); + await expect(page.getByRole('cell', { name: '600', exact: true })).toBeVisible(); + expect(new URL(page.url()).pathname).toBe(gamePath('/play-audit')); +}); + +test('mobile city drilldown preserves month and includes foreign stationed generals', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + const requests = await install(page); + await page.goto(gamePath('/play-audit?tab=cities&nation=2&at=month&year=190&month=6')); + await page.getByText('내정 보기', { exact: true }).click(); + await expect(page.getByText(/농업 100 \/ 200/)).toBeVisible(); + await capture(page, 'mobile-cities'); + await page.getByRole('button', { name: '모든 국가의 주둔 장수' }).click(); + await expect(page.getByRole('rowheader', { name: /감사장수/ })).toBeVisible(); + const input = requests.filter((request) => request.operation === 'playAudit.generals').at(-1)?.input; + expect(input).toMatchObject({ cityId: 3, at: { year: 190, month: 6, kind: 'MONTH_END' } }); + expect(input).not.toHaveProperty('nationId'); + await page.getByText('상세 보기', { exact: true }).click(); + await expect(page.getByText(/통솔 80/)).toBeVisible(); + await capture(page, 'mobile-generals'); + await page.getByRole('button', { name: '다음 50개 불러오기' }).click(); + await expect(page.getByRole('rowheader', { name: /다음장수/ })).toBeVisible(); + await page.goBack(); + await expect(page.getByRole('rowheader', { name: /성도/ })).toBeVisible(); + await page.goto(gamePath('/play-audit?tab=generals&at=month&year=190&month=7')); + await expect(page.getByText('선택한 시점의 표본이 없습니다.')).toBeVisible(); +}); + +test('denied capability does not request game audit data', async ({ page }) => { + const requests = await install(page, true); + await page.goto(gamePath('/play-audit')); + await expect(page.getByRole('alert')).toContainText('플레이 감사 권한'); + expect( + requests.filter((request) => request.operation.startsWith('playAudit.')).map((request) => request.operation) + ).toEqual(['playAudit.capabilities']); + await expect(page.getByLabel('조회 대상')).toHaveCount(0); +}); + +test('back navigation during a slow read keeps the newer city view', async ({ page }) => { + await install(page); + let release = () => {}; + let markStarted = () => {}; + const held = new Promise((resolve) => { + release = resolve; + }); + const started = new Promise((resolve) => { + markStarted = resolve; + }); + await page.route(gameTrpcRoute, async (route) => { + if (route.request().url().includes('playAudit.generals')) { + markStarted(); + await held; + } + await route.fallback(); + }); + await page.goto(gamePath('/play-audit?tab=cities')); + await page.getByRole('button', { name: '모든 국가의 주둔 장수' }).click(); + await started; + await expect(page.getByRole('button', { name: '조회', exact: true })).toBeDisabled(); + await page.goBack(); + await expect(page.getByRole('rowheader', { name: /성도/ })).toBeVisible(); + const response = page.waitForResponse((response) => response.url().includes('playAudit.generals')); + release(); + await response; + await expect(page.getByRole('rowheader', { name: /성도/ })).toBeVisible(); + await expect(page.getByRole('rowheader', { name: /감사장수/ })).toHaveCount(0); + await page.getByRole('button', { name: '조회', exact: true }).focus(); + await expect(page.getByRole('button', { name: '조회', exact: true })).toBeFocused(); +}); diff --git a/app/game-frontend/e2e/playwright.config.mjs b/app/game-frontend/e2e/playwright.config.mjs index bd716471..fc56ea6e 100644 --- a/app/game-frontend/e2e/playwright.config.mjs +++ b/app/game-frontend/e2e/playwright.config.mjs @@ -19,6 +19,7 @@ const frontendEnv = export default defineConfig({ testDir: '.', testMatch: [ + 'playAudit.spec.ts', 'troop.spec.ts', 'typographyPolicy.spec.ts', 'board.spec.ts', diff --git a/app/game-frontend/src/assets/main.css b/app/game-frontend/src/assets/main.css index 72118f1c..5df1ea2e 100644 --- a/app/game-frontend/src/assets/main.css +++ b/app/game-frontend/src/assets/main.css @@ -45,7 +45,8 @@ body { #app:has(.interface-settings-page), #app:has(#tournament-container), #app:has(#tournament-betting-container), -#app:has(#personnel-container) { +#app:has(#personnel-container), +#app:has(#play-audit-container) { min-width: 320px; } diff --git a/app/game-frontend/src/components/playAudit/AuditNationSeries.vue b/app/game-frontend/src/components/playAudit/AuditNationSeries.vue new file mode 100644 index 00000000..0c8f8d29 --- /dev/null +++ b/app/game-frontend/src/components/playAudit/AuditNationSeries.vue @@ -0,0 +1,180 @@ + + + + + diff --git a/app/game-frontend/src/router/index.ts b/app/game-frontend/src/router/index.ts index f406cfb1..17563413 100644 --- a/app/game-frontend/src/router/index.ts +++ b/app/game-frontend/src/router/index.ts @@ -56,6 +56,12 @@ const accessPageByRouteName = { } as const; const routes = [ + { + path: '/play-audit', + name: 'play-audit', + component: () => import('../views/PlayAuditView.vue'), + meta: { requiresAuth: true }, + }, { path: '/', name: 'home', diff --git a/app/game-frontend/src/views/PlayAuditView.vue b/app/game-frontend/src/views/PlayAuditView.vue new file mode 100644 index 00000000..ea8d6513 --- /dev/null +++ b/app/game-frontend/src/views/PlayAuditView.vue @@ -0,0 +1,533 @@ + + + + + diff --git a/docs/design/play-audit-implementation.md b/docs/design/play-audit-implementation.md index 89a03caf..510d88cd 100644 --- a/docs/design/play-audit-implementation.md +++ b/docs/design/play-audit-implementation.md @@ -1,11 +1,37 @@ # 플레이 감사 구현 기록과 수집 inventory [확정 설계](play-audit.md)의 P1~P6를 구현하는 작업 기록이다. 전체 기능은 진행 중이며, -월별 projection과 runtime 수집·DB transaction 연결을 구현했다. 프로필 권한과 장수·도시 현재/월말 조회 API를 연결했다. 화면과 나머지 조회는 미구현이다. +월별 projection과 runtime 수집·DB transaction 연결을 구현했다. 프로필 권한과 장수·도시 현재/월말 조회 API, +국가 월/반기 시계열과 `/play-audit` 기본 조회 화면을 연결했다. 외교·정책·NPC trace·조사 도구는 미구현이다. Push 요청 이후 `feat/play-audit` 전용 worktree에서 계속 구현하며 전체 완료 후 main 통합·push한다. ## 현재 구현 +### 기본 조회 화면 + +프로필 game frontend의 `/play-audit`는 장수가 없는 감사 계정도 직접 접근한다. +`capabilities`가 허용된 뒤 coverage와 국가 목록을 읽고 선택한 조회만 요청한다. +권한 거부 시 다른 감사 자료를 미리 가져오지 않는다. URL에 탭·국가·도시·표본 월·기간을 +보존하며 도시의 주둔 장수 연결은 당시 월을 유지하고 국가 필터를 해제한다. +장수·도시 목록은 50개씩 명시적으로 더 읽는다. 느린 이전 응답은 후속 조회를 덮지 않는다. + +국가 목록은 현재 또는 한 월의 이름/ID/color만 반환한다. 현재 목록은 해당 세 필드만 +SELECT하며 과거 목록은 한 표본의 국가 JSON을 51행까지 읽고 allowlist projection한다. +기본 50·최대 200과 ID cursor를 사용하고 기수 전체의 국가를 DISTINCT 스캔하지 않는다. +멸망국은 해당 월 기준 목록으로 선택한다. 국가 시계열의 기본 범위는 최근 6개월이며 +지표·집단 전환은 이미 받은 집계에서 계산해 추가 요청을 하지 않는다. + +PanelCard, legacy-button, legacy-sort-select를 재사용한다. 새 차트 라이브러리 없이 +표의 막대와 수치를 함께 표시한다. stock 마지막 표본 월, 월별 수집 여부·국가 존재·정산 +완전성을 펼쳐볼 수 있고 null은 `자료 없음`이다. 국가 보유 금쌀/기술/세율, +수입·지급, 집단 인원·보유 총량/평균·5병종 평균 숙련 지표를 제공한다. + +이 화면은 Core 신규 UX다. 최대 폭 1200px, 390px 모바일에서 문서 가로 넘침 없음, +넓은 표만 내부 수평 스크롤, 공통 14px 기본 typography와 명시적 focus/disabled가 계약이다. +월말/FINAL 장수·도시 projection을 보여주지만 국가 FINAL 별도 시계열, 지도, +로그/예약 명령/전투 상세, 검색·정렬, 관리자 패널 진입 버튼은 후속 구현으로 남는다. +따라서 기본 화면 추가만으로 R1~R3/P2를 완료 처리하지 않는다. + `app/game-engine/src/playAudit/snapshot.ts`는 기존 메모리 엔티티에서 명시적으로 허용한 장수·도시 필드와 국가별 자원·숙련 집계를 만든다. 입력 iterable을 각각 한 번 순회하며 국가마다 장수 목록을 다시 검색하지 않는다. 장수의 stats/role/items도