From 51d0317a69e477f5840f26c6faf8f3e1a695e148 Mon Sep 17 00:00:00 2001 From: hided62 Date: Sat, 26 Sep 2026 04:52:18 +0000 Subject: [PATCH] =?UTF-8?q?=ED=94=8C=EB=A0=88=EC=9D=B4=20=EA=B0=90?= =?UTF-8?q?=EC=82=AC=EC=97=90=20=EC=9E=A5=EC=88=98=20=EC=88=98=EC=B9=98=20?= =?UTF-8?q?=EC=A0=95=EB=A0=AC=EA=B3=BC=20=EC=8B=9C=EC=A0=90=EB=B3=84=20?= =?UTF-8?q?=EB=8F=84=EC=8B=9C=20=EC=A7=80=EB=8F=84=EB=A5=BC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/game-api/src/router/playAudit/cityMap.ts | 93 +++++++++++++ .../src/router/playAudit/generalSort.ts | 108 +++++++++++++++ app/game-api/src/router/playAudit/index.ts | 13 ++ .../securityTransport.integration.test.ts | 126 ++++++++++++++++++ app/game-frontend/e2e/playAudit.spec.ts | 68 +++++++++- app/game-frontend/src/views/PlayAuditView.vue | 85 +++++++++++- docs/design/play-audit-implementation.md | 29 +++- 7 files changed, 514 insertions(+), 8 deletions(-) create mode 100644 app/game-api/src/router/playAudit/cityMap.ts create mode 100644 app/game-api/src/router/playAudit/generalSort.ts diff --git a/app/game-api/src/router/playAudit/cityMap.ts b/app/game-api/src/router/playAudit/cityMap.ts new file mode 100644 index 00000000..f622fc7f --- /dev/null +++ b/app/game-api/src/router/playAudit/cityMap.ts @@ -0,0 +1,93 @@ +import { TRPCError } from '@trpc/server'; +import { asRecord } from '@sammo-ts/common'; +import { z } from 'zod'; +import { loadMapLayout, loadMapLayoutByName } from '../../maps/mapLayout.js'; +import { auditProcedure, readAudit, readAuditWorld, findAuditMonth, zAuditMonth } from './shared.js'; +import { citySelect, projectCurrentCity, zAuditCityData } from './projection.js'; + +/** 도시 표본 전체를 한 지도 단위로 읽는다. 장수/로그/trace는 읽지 않는다. */ +export const cityMap = auditProcedure.input(z.object({ at: zAuditMonth.optional() }).strict()).query(({ ctx, input }) => + readAudit(ctx, async (tx) => { + const world = await readAuditWorld(tx); + const sample = input.at ? await findAuditMonth(tx, world, input.at) : null; + if (input.at && !sample) + return { + ...world, + collected: false, + sample, + map: null, + layout: null, + unmappedCityIds: [], + nextCursor: null, + }; + const state = await tx.worldState.findFirst({ orderBy: { id: 'asc' }, select: { config: true } }); + const environment = asRecord(asRecord(state?.config).environment); + const layout = + typeof environment.mapName === 'string' && environment.mapName.trim() + ? await loadMapLayoutByName(environment.mapName) + : await loadMapLayout(ctx.profile.scenario); + const cities = sample + ? ( + await tx.playAuditCity.findMany({ + where: { sampleId: sample.id }, + select: { data: true }, + take: 1025, + }) + ).map((row) => zAuditCityData.parse(row.data)) + : (await tx.city.findMany({ select: citySelect, take: 1025 })).map(projectCurrentCity); + if (cities.length > 1024) + throw new TRPCError({ + code: 'BAD_REQUEST', + message: '지도 조회 범위를 초과했습니다. 도시 목록으로 조회해 주세요.', + }); + const nationIds = [...new Set(cities.map((city) => city.nationId))]; + const identity = z.object({ id: z.number(), name: z.string(), color: z.string() }); + const nations = sample + ? ( + await tx.playAuditNation.findMany({ + where: { sampleId: sample.id, nationId: { in: nationIds } }, + select: { data: true }, + }) + ).map((row) => ({ ...identity.parse(row.data), capitalCityId: 0 })) + : await tx.nation.findMany({ + where: { id: { in: nationIds } }, + select: { id: true, name: true, color: true, capitalCityId: true }, + }); + const byCity = new Map(cities.map((city) => [city.id, city])); + const byNation = new Map(nations.map((nation) => [nation.id, nation])); + const layoutIds = new Set(layout.cityList.map((city) => city.id)); + // 없는 표본을 무주 도시로 그리지 않는다. 당시 이름과 소유만 사용한다. + const visibleLayout = { + ...layout, + cityList: layout.cityList + .filter((city) => byCity.has(city.id)) + .map((city) => ({ ...city, name: byCity.get(city.id)!.name })), + }; + return { + ...world, + collected: true, + sample, + layout: visibleLayout, + nextCursor: null, + unmappedCityIds: cities.filter((city) => !layoutIds.has(city.id)).map((city) => city.id), + map: { + year: input.at?.year ?? world.year, + month: input.at?.month ?? world.month, + startYear: world.startYear, + cityList: visibleLayout.cityList.map((layoutCity): [number, number, number, number, number, number] => { + const city = byCity.get(layoutCity.id)!; + return [city.id, city.level, city.state, city.nationId, layoutCity.region, city.supplyState]; + }), + nationList: nationIds.map((id): [number, string, string, number] => { + const nation = byNation.get(id); + return [ + id, + nation?.name ?? (id === 0 ? '무주' : `국가 #${id} (미수집)`), + nation?.color ?? '#888888', + nation?.capitalCityId ?? 0, + ]; + }), + }, + }; + }) +); diff --git a/app/game-api/src/router/playAudit/generalSort.ts b/app/game-api/src/router/playAudit/generalSort.ts new file mode 100644 index 00000000..3da84be6 --- /dev/null +++ b/app/game-api/src/router/playAudit/generalSort.ts @@ -0,0 +1,108 @@ +import { TRPCError } from '@trpc/server'; +import { GamePrisma } from '@sammo-ts/infra'; +import { z } from 'zod'; +import { generalSelect, projectCurrentGeneral, zAuditGeneralData } from './projection.js'; + +export const zGeneralSort = z.enum([ + 'id', + 'gold', + 'rice', + 'crew', + 'train', + 'atmos', + 'leadership', + 'strength', + 'intelligence', + 'experience', + 'dedication', + 'dex1', + 'dex2', + 'dex3', + 'dex4', + 'dex5', +]); +export const zGeneralCursor = z.object({ id: z.number().int().nonnegative(), value: z.number().nullable() }).strict(); +const columns: Record, string> = { + id: 'id', + gold: 'gold', + rice: 'rice', + crew: 'crew', + train: 'train', + atmos: 'atmos', + leadership: 'leadership', + strength: 'strength', + intelligence: 'intel', + experience: 'experience', + dedication: 'dedication', + dex1: 'dex1', + dex2: 'dex2', + dex3: 'dex3', + dex4: 'dex4', + dex5: 'dex5', +}; +export const readSortedGenerals = async ( + tx: GamePrisma.TransactionClient, + input: { + sort: z.infer; + order: 'asc' | 'desc'; + limit: number; + cursor?: number | z.infer; + nationId?: number; + cityId?: number; + population?: 'human' | 'npc' | 'troopNpc'; + name?: string; + }, + sampleId?: string +) => { + if (typeof input.cursor === 'number') + throw new TRPCError({ code: 'BAD_REQUEST', message: '정렬 조건에 맞는 다음 페이지를 선택해 주세요.' }); + // SQL 식별자/JSON 경로는 서버 allowlist에서만 조립한다. 사용자 문자열은 parameter다. + const id = GamePrisma.raw(sampleId ? 'general_id' : 'id'); + const path = ['leadership', 'strength', 'intelligence'].includes(input.sort) + ? `stats,${input.sort}` + : input.sort.startsWith('dex') + ? `dex,${input.sort}` + : input.sort; + const metric = sampleId + ? GamePrisma.raw(`(data #>> '{${path}}')::double precision`) + : input.sort.startsWith('dex') + ? GamePrisma.raw( + `CASE WHEN jsonb_typeof(meta->'${input.sort}') = 'number' THEN (meta->>'${input.sort}')::double precision ELSE 0 END` + ) + : GamePrisma.raw(columns[input.sort]); + const filters: GamePrisma.Sql[] = []; + if (sampleId) filters.push(GamePrisma.sql`sample_id = ${sampleId}`); + if (input.nationId !== undefined) filters.push(GamePrisma.sql`nation_id = ${input.nationId}`); + if (input.cityId !== undefined) filters.push(GamePrisma.sql`city_id = ${input.cityId}`); + if (input.population === 'human') filters.push(GamePrisma.sql`npc_state < 2`); + if (input.population === 'npc') filters.push(GamePrisma.sql`npc_state >= 2 AND npc_state <> 5`); + if (input.population === 'troopNpc') filters.push(GamePrisma.sql`npc_state = 5`); + if (input.name) + filters.push( + GamePrisma.sql`${GamePrisma.raw(sampleId ? "data->>'name'" : 'name')} LIKE ${`%${input.name.replace(/[\\%_]/g, '\\$&')}%`}` + ); + if (input.cursor) { + const { value, id: cursorId } = input.cursor; + filters.push( + value === null + ? GamePrisma.sql`(${metric} IS NULL AND ${id} > ${cursorId})` + : GamePrisma.sql`(${metric} ${GamePrisma.raw(input.order === 'asc' ? '>' : '<')} ${value} OR (${metric} = ${value} AND ${id} > ${cursorId}) OR ${metric} IS NULL)` + ); + } + const rows = await tx.$queryRaw<{ id: number; value: number | null; data: unknown }[]>(GamePrisma.sql` + SELECT ${id} AS id, ${metric} AS value, ${sampleId ? GamePrisma.raw('data') : GamePrisma.sql`NULL`} AS data + FROM ${GamePrisma.raw(sampleId ? 'play_audit_general' : 'general')} + WHERE ${filters.length ? GamePrisma.join(filters, ' AND ') : GamePrisma.sql`TRUE`} + ORDER BY ${metric} ${GamePrisma.raw(input.order.toUpperCase())} NULLS LAST, ${id} ASC LIMIT ${input.limit + 1} + `); + const page = rows.slice(0, input.limit); + const current = sampleId + ? [] + : await tx.general.findMany({ where: { id: { in: page.map((row) => row.id) } }, select: generalSelect }); + const byId = new Map(current.map((row) => [row.id, projectCurrentGeneral(row)])); + return { + items: page.map((row) => (sampleId ? zAuditGeneralData.parse(row.data) : byId.get(row.id)!)), + nextCursor: + rows.length > input.limit ? { id: page[page.length - 1]!.id, value: page[page.length - 1]!.value } : null, + }; +}; diff --git a/app/game-api/src/router/playAudit/index.ts b/app/game-api/src/router/playAudit/index.ts index feb54795..4b025016 100644 --- a/app/game-api/src/router/playAudit/index.ts +++ b/app/game-api/src/router/playAudit/index.ts @@ -1,3 +1,6 @@ +import { cityMap } from './cityMap.js'; +import { TRPCError } from '@trpc/server'; +import { readSortedGenerals, zGeneralSort, zGeneralCursor } from './generalSort.js'; import { requestState } from './requests.js'; import { decisionHistory, decisionDetail } from './decisions.js'; import { diplomacyHistory, diplomacyEvent } from './diplomacy.js'; @@ -27,6 +30,7 @@ import { } from './projection.js'; export const playAuditRouter = router({ + cityMap, requestState, decisionHistory, decisionDetail, @@ -161,11 +165,20 @@ export const playAuditRouter = router({ population: z.enum(['human', 'npc', 'troopNpc']).optional(), name: z.string().trim().max(64).optional(), order: z.enum(['asc', 'desc']).default('asc'), + sort: zGeneralSort.default('id'), + cursor: z.union([z.number().int().nonnegative(), zGeneralCursor]).optional(), }) ) .query(({ ctx, input }) => readAudit(ctx, async (tx) => { const world = await readAuditWorld(tx); + if (input.sort !== 'id') { + const sample = input.at ? await findAuditMonth(tx, world, input.at) : null; + if (input.at && !sample) return { ...world, sample, collected: false, items: [], nextCursor: null }; + return { ...world, sample, collected: true, ...(await readSortedGenerals(tx, input, sample?.id)) }; + } + if (typeof input.cursor === 'object') + throw new TRPCError({ code: 'BAD_REQUEST', message: '장수 번호 정렬의 다음 페이지가 아닙니다.' }); const npcState = input.population === 'human' ? { lt: 2 } diff --git a/app/game-api/test/securityTransport.integration.test.ts b/app/game-api/test/securityTransport.integration.test.ts index 77b2f970..f1af31f4 100644 --- a/app/game-api/test/securityTransport.integration.test.ts +++ b/app/game-api/test/securityTransport.integration.test.ts @@ -2852,6 +2852,132 @@ integration('game API security over HTTP transport', () => { expect((await get('capabilities', admin)).body).toMatchObject({ result: { data: { read: true, accounts: false } }, }); + // 현재/과거 정렬은 전체 조건 안에서 수행하고 동점은 ID로 안정적으로 넘긴다. + const originalSortRows = await db.general.findMany({ + where: { id: { in: [generalId, sameNationGeneralId] } }, + select: { id: true, gold: true }, + }); + await db.general.updateMany({ + where: { id: { in: [generalId, sameNationGeneralId] } }, + data: { gold: 54321 }, + }); + const sortedInput = { nationId: ownerNationId, sort: 'gold', order: 'desc', limit: 1 }; + expect((await get('generals', admin, sortedInput)).body).toMatchObject({ + result: { + data: { + items: [{ id: generalId, gold: 54321 }], + nextCursor: { id: generalId, value: 54321 }, + }, + }, + }); + expect( + (await get('generals', admin, { ...sortedInput, cursor: { id: generalId, value: 54321 } })).body + ).toMatchObject({ result: { data: { items: [{ id: sameNationGeneralId }] } } }); + for (const row of originalSortRows) + await db.general.update({ where: { id: row.id }, data: { gold: row.gold } }); + for (const sort of [ + 'gold', + 'rice', + 'crew', + 'train', + 'atmos', + 'leadership', + 'strength', + 'intelligence', + 'experience', + 'dedication', + 'dex1', + 'dex2', + 'dex3', + 'dex4', + 'dex5', + ]) { + for (const at of [undefined, { year: 190, month: 1 }]) { + expect((await get('generals', admin, { sort, at })).status).toBe(200); + } + } + const oldGeneral = await db.playAuditGeneral.findUniqueOrThrow({ + where: { sampleId_generalId: { sampleId, generalId } }, + }); + await db.playAuditGeneral.create({ + data: { + sampleId, + generalId: sameNationGeneralId, + nationId: oldGeneral.nationId, + cityId: oldGeneral.cityId, + npcState: oldGeneral.npcState, + data: { ...asRecord(oldGeneral.data), id: sameNationGeneralId, name: '동점과거장수' }, + }, + }); + const historicalValue = past.gold; + expect( + (await get('generals', admin, { sort: 'gold', order: 'desc', at: { year: 190, month: 1 }, limit: 1 })) + .body + ).toMatchObject({ + result: { data: { items: [{ id: generalId }], nextCursor: { id: generalId, value: historicalValue } } }, + }); + expect( + ( + await get('generals', admin, { + sort: 'gold', + order: 'desc', + at: { year: 190, month: 1 }, + limit: 1, + cursor: { id: generalId, value: historicalValue }, + }) + ).body + ).toMatchObject({ result: { data: { items: [{ id: sameNationGeneralId }], nextCursor: null } } }); + await db.playAuditGeneral.delete({ + where: { sampleId_generalId: { sampleId, generalId: sameNationGeneralId } }, + }); + expect((await get('generals', admin, { sort: 'gold', cursor: 1 })).status).toBe(400); + expect((await get('generals', admin, { sort: 'gold; DROP TABLE general' })).status).toBe(400); + expect( + (await get('generals', admin, { sort: 'gold', at: { year: 190, month: 1 }, name: '%' })).body + ).toMatchObject({ result: { data: { items: [] } } }); + for (const accessToken of [ + undefined, + await token(['admin']), + await token(['admin.playAudit.read:other:default']), + ]) { + expect((await get('cityMap', accessToken, {})).status).toBe(accessToken ? 403 : 401); + expect((await get('generals', accessToken, { sort: 'crew' })).status).toBe(accessToken ? 403 : 401); + } + const citySource = await db.playAuditCity.findFirstOrThrow({ where: { sampleId }, select: { data: true } }); + await db.playAuditCity.create({ + data: { + sampleId, + cityId: 1, + nationId: ownerNationId, + data: { ...asRecord(citySource.data), id: 1, name: '지도과거도시' }, + }, + }); + const map = await get('cityMap', admin, { at: { year: 190, month: 1 } }); + expect(map.status).toBe(200); + expect(map.body).toMatchObject({ + result: { + data: { + collected: true, + map: { + year: 190, + month: 1, + cityList: expect.arrayContaining([[1, 4, 0, ownerNationId, expect.any(Number), 1]]), + }, + layout: { + cityList: expect.arrayContaining([ + expect.objectContaining({ id: 1, name: '지도과거도시' }), + ]), + }, + }, + }, + }); + expect(JSON.stringify(map.body)).not.toContain('must-not-expose'); + await db.playAuditCity.delete({ where: { sampleId_cityId: { sampleId, cityId: 1 } } }); + expect((await get('cityMap', admin, { at: { year: 190, month: 2 } })).body).toMatchObject({ + result: { data: { collected: false, map: null } }, + }); + expect((await get('cityMap', admin, {})).status).toBe(200); + expect((await get('cityMap', admin, { at: { year: 9999, month: 1 } })).status).toBe(400); const first = await get('generals', admin, { limit: 1 }); expect(first.status).toBe(200); expect(first.body).toMatchObject({ diff --git a/app/game-frontend/e2e/playAudit.spec.ts b/app/game-frontend/e2e/playAudit.spec.ts index 730d90dd..fce49fcd 100644 --- a/app/game-frontend/e2e/playAudit.spec.ts +++ b/app/game-frontend/e2e/playAudit.spec.ts @@ -546,7 +546,11 @@ const install = async ( ...world, collected: !input.at || (input.at as { month: number }).month !== 7, sample: input.at ?? null, - nextCursor: input.cursor ? null : 1, + nextCursor: input.cursor + ? null + : input.sort && input.sort !== 'id' + ? { id: 1, value: 1200 } + : 1, items: input.at && (input.at as { month: number }).month === 7 ? [] @@ -567,6 +571,27 @@ const install = async ( nation: { id: 2, name: '촉' }, city: { id: 3, name: '성도' }, }); + case 'playAudit.cityMap': + return result({ + ...world, + collected: true, + sample: input.at ?? null, + nextCursor: null, + unmappedCityIds: [], + layout: { + mapName: 'che', + cityList: [{ id: 3, name: '성도', level: 4, region: 1, x: 200, y: 180, path: [] }], + regionMap: { 1: '익주' }, + levelMap: { 4: '이' }, + }, + map: { + year: 190, + month: (input.at as { month: number } | undefined)?.month ?? 7, + startYear: 190, + cityList: [[3, 4, 0, 2, 1, 1]], + nationList: [[2, '촉', '#ff0000', 0]], + }, + }); case 'playAudit.cityDetail': return result({ ...world, @@ -1455,3 +1480,44 @@ for (const width of [1440, 390]) { if (width < 1200) await expect(detail).toBeInViewport(); }); } + +for (const width of [1280, 390]) { + test(`audit historical map and ranked generals at ${width}px`, async ({ page }) => { + await page.setViewportSize({ width, height: 900 }); + await page.route('https://sam-image.hided.net/**', (route) => + route.fulfill({ + contentType: 'image/svg+xml', + body: '', + }) + ); + const requests = await install(page); + await page.goto(gamePath('/play-audit?tab=cities&at=month&year=190&month=6')); + expect(requests.some((request) => request.operation === 'playAudit.cityMap')).toBe(false); + await page.getByRole('link', { name: '도시 지도', exact: true }).click(); + await expect(page.getByRole('button', { name: '성도', exact: true })).toBeVisible(); + await page.getByRole('button', { name: '성도', exact: true }).click(); + await expect(page).toHaveURL(/cityRecord=3/); + await expect(page.getByRole('heading', { name: '성도 (#3) · 촉' })).toBeVisible(); + await capture(page, `audit-map-${width}`); + await page.reload(); + await expect(page.getByRole('button', { name: '성도', exact: true })).toBeVisible(); + await page.getByRole('button', { name: '이 시점의 모든 국가 주둔 장수', exact: true }).click(); + await expect(page).toHaveURL(/city=3/); + await page.getByLabel('정렬 기준', { exact: true }).selectOption('gold'); + await page.getByLabel('정렬 방향', { exact: true }).selectOption('desc'); + await page.getByRole('button', { name: '조회', exact: true }).click(); + await expect(page).toHaveURL(/sort=gold/); + await page.getByRole('button', { name: '다음 50개 불러오기' }).click(); + await expect(page.getByRole('rowheader', { name: /다음장수/ })).toBeVisible(); + expect(requests.filter((request) => request.operation === 'playAudit.generals').at(-1)?.input).toMatchObject({ + sort: 'gold', + order: 'desc', + cityId: 3, + at: { year: 190, month: 6, kind: 'MONTH_END' }, + cursor: { id: 1, value: 1200 }, + }); + await page.reload(); + await expect(page.getByLabel('정렬 기준', { exact: true })).toHaveValue('gold'); + await capture(page, `audit-ranked-${width}`); + }); +} diff --git a/app/game-frontend/src/views/PlayAuditView.vue b/app/game-frontend/src/views/PlayAuditView.vue index 5a939615..154ed12d 100644 --- a/app/game-frontend/src/views/PlayAuditView.vue +++ b/app/game-frontend/src/views/PlayAuditView.vue @@ -1,6 +1,7 @@