diff --git a/app/game-api/src/router/playAudit/details.ts b/app/game-api/src/router/playAudit/details.ts new file mode 100644 index 00000000..6eba0f13 --- /dev/null +++ b/app/game-api/src/router/playAudit/details.ts @@ -0,0 +1,138 @@ +import { z } from 'zod'; +import { auditProcedure, findAuditMonth, readAudit, readAuditWorld, zAuditMonth } from './shared.js'; +import { + citySelect, + generalSelect, + projectCurrentCity, + projectCurrentGeneral, + zAuditCityData, + zAuditGeneralData, +} from './projection.js'; + +const identity = z.object({ id: z.number(), name: z.string() }); +const zDetail = z.object({ id: z.number().int().nonnegative(), at: zAuditMonth.optional() }).strict(); + +export const generalDetail = auditProcedure.input(zDetail).query(({ ctx, input }) => + readAudit(ctx, async (tx) => { + const world = await readAuditWorld(tx); + if (input.at) { + const sample = await findAuditMonth(tx, world, input.at); + const row = sample + ? await tx.playAuditGeneral.findUnique({ + where: { sampleId_generalId: { sampleId: sample.id, generalId: input.id } }, + select: { data: true }, + }) + : null; + const general = row ? zAuditGeneralData.parse(row.data) : null; + const nation = + general && sample + ? await tx.playAuditNation.findUnique({ + where: { sampleId_nationId: { sampleId: sample.id, nationId: general.nationId } }, + select: { data: true }, + }) + : null; + const city = + general && sample + ? await tx.playAuditCity.findUnique({ + where: { sampleId_cityId: { sampleId: sample.id, cityId: general.cityId } }, + select: { data: true }, + }) + : null; + return { + ...world, + sample, + collected: Boolean(sample), + general, + nation: nation ? identity.parse(nation.data) : null, + city: city ? identity.parse(city.data) : null, + }; + } + const row = await tx.general.findUnique({ where: { id: input.id }, select: generalSelect }); + const general = row ? projectCurrentGeneral(row) : null; + const nation = general + ? await tx.nation.findUnique({ where: { id: general.nationId }, select: { id: true, name: true } }) + : null; + const city = general + ? await tx.city.findUnique({ where: { id: general.cityId }, select: { id: true, name: true } }) + : null; + return { ...world, sample: null, collected: true, general, nation, city }; + }) +); + +export const cityDetail = auditProcedure.input(zDetail).query(({ ctx, input }) => + readAudit(ctx, async (tx) => { + const world = await readAuditWorld(tx); + if (input.at) { + const sample = await findAuditMonth(tx, world, input.at); + const row = sample + ? await tx.playAuditCity.findUnique({ + where: { sampleId_cityId: { sampleId: sample.id, cityId: input.id } }, + select: { data: true }, + }) + : null; + const city = row ? zAuditCityData.parse(row.data) : null; + const nation = + city && sample + ? await tx.playAuditNation.findUnique({ + where: { sampleId_nationId: { sampleId: sample.id, nationId: city.nationId } }, + select: { data: true }, + }) + : null; + return { + ...world, + sample, + collected: Boolean(sample), + city, + nation: nation ? identity.parse(nation.data) : null, + }; + } + const row = await tx.city.findUnique({ where: { id: input.id }, select: citySelect }); + const city = row ? projectCurrentCity(row) : null; + const nation = city + ? await tx.nation.findUnique({ where: { id: city.nationId }, select: { id: true, name: true } }) + : null; + return { ...world, sample: null, collected: true, city, nation }; + }) +); + +// 현재 예약은 과거 표본과 분리한다. 잘못된 slot도 숨기지 않고 페이지로 조회한다. +export const generalTurns = auditProcedure + .input( + z + .object({ + generalId: z.number().int().nonnegative(), + cursor: z.number().int().optional(), + limit: z.number().int().min(1).max(200).default(50), + }) + .strict() + ) + .query(({ ctx, input }) => + readAudit(ctx, async (tx) => { + const world = await readAuditWorld(tx); + const general = await tx.general.findUnique({ where: { id: input.generalId }, select: { id: true } }); + const rows = general + ? await tx.generalTurn.findMany({ + where: { + generalId: input.generalId, + turnIdx: input.cursor === undefined ? undefined : { gt: input.cursor }, + }, + orderBy: { turnIdx: 'asc' }, + take: input.limit + 1, + select: { turnIdx: true, actionCode: true, arg: true }, + }) + : []; + return { + ...world, + currentOnly: true, + generalExists: Boolean(general), + items: rows + .slice(0, input.limit) + .map((row) => ({ + turnIdx: row.turnIdx, + actionCode: row.actionCode, + argumentJson: JSON.stringify(row.arg), + })), + nextCursor: rows.length > input.limit ? rows[input.limit - 1]!.turnIdx : null, + }; + }) + ); diff --git a/app/game-api/src/router/playAudit/index.ts b/app/game-api/src/router/playAudit/index.ts index 1fd92d86..b52326e9 100644 --- a/app/game-api/src/router/playAudit/index.ts +++ b/app/game-api/src/router/playAudit/index.ts @@ -1,4 +1,5 @@ import { nationSeries, zAuditNation } from './nationSeries.js'; +import { cityDetail, generalDetail, generalTurns } from './details.js'; import { z } from 'zod'; import { canReadPlayAuditAccounts } from '@sammo-ts/common'; import { router } from '../../trpc.js'; @@ -21,6 +22,9 @@ import { } from './projection.js'; export const playAuditRouter = router({ + cityDetail, + generalDetail, + generalTurns, nationSeries, nationSnapshot: auditProcedure .input(z.object({ nationId: z.number().int().nonnegative(), at: zAuditMonth }).strict()) diff --git a/app/game-api/test/securityTransport.integration.test.ts b/app/game-api/test/securityTransport.integration.test.ts index 67bde580..bba77a02 100644 --- a/app/game-api/test/securityTransport.integration.test.ts +++ b/app/game-api/test/securityTransport.integration.test.ts @@ -2197,23 +2197,90 @@ integration('game API security over HTTP transport', () => { kind: 'MONTH_END', settlementsComplete: true, hash: 'http-fixture', + cities: { + create: { + cityId: 99123, + nationId: ownerNationId, + data: { + id: 99123, + name: '과거도시', + nationId: ownerNationId, + level: 4, + state: 0, + population: 100, + populationMax: 200, + agriculture: 10, + agricultureMax: 20, + commerce: 10, + commerceMax: 20, + security: 10, + securityMax: 20, + wall: 10, + wallMax: 20, + defence: 10, + defenceMax: 20, + supplyState: 1, + frontState: 0, + trust: 80, + }, + }, + }, generals: { create: { generalId, nationId: current.nationId, - cityId: current.cityId, + cityId: 99123, npcState: current.npcState, - data: { ...past, name: '과거이름', hiddenSecret: 'must-not-expose' }, + data: { ...past, cityId: 99123, name: '과거이름', hiddenSecret: 'must-not-expose' }, }, }, }, }); + const admin = await token([`admin.playAudit.read:${profileName}`]); const beforeInputs = await db.inputEvent.count(); + expect((await get('generalDetail', admin, { id: generalId })).body).toMatchObject({ + result: { data: { collected: true, general: { id: generalId, name: current.name } } }, + }); + expect( + (await get('generalDetail', admin, { id: generalId, at: { year: 190, month: 1 } })).body + ).toMatchObject({ + result: { + data: { collected: true, general: { name: '과거이름' }, city: { id: 99123, name: '과거도시' } }, + }, + }); + expect( + (await get('generalDetail', admin, { id: generalId, at: { year: 190, month: 2 } })).body + ).toMatchObject({ result: { data: { collected: false, general: null } } }); + expect((await get('generalDetail', await token(['admin']), { id: generalId })).status).toBe(403); + expect((await get('cityDetail', admin, { id: 99123, at: { year: 190, month: 1 } })).body).toMatchObject({ + result: { data: { collected: true, city: { id: 99123, name: '과거도시', population: 100 } } }, + }); + await db.generalTurn.createMany({ + data: [9001, 9002].map((turnIdx) => ({ + generalId, + turnIdx, + actionCode: '휴식', + arg: { fixture: turnIdx }, + })), + }); + expect((await get('generalTurns', admin, { generalId, cursor: 9000, limit: 1 })).body).toMatchObject({ + result: { + data: { + currentOnly: true, + items: [{ turnIdx: 9001, actionCode: '휴식', argumentJson: '{"fixture":9001}' }], + nextCursor: 9001, + }, + }, + }); + expect((await get('generalTurns', admin, { generalId, cursor: 9001, limit: 1 })).body).toMatchObject({ + result: { data: { items: [{ turnIdx: 9002 }], nextCursor: null } }, + }); + expect((await get('generalTurns', admin, { generalId, at: { year: 190, month: 1 } })).status).toBe(400); + expect((await get('generalTurns', admin, { generalId, limit: 201 })).status).toBe(400); expect((await get('capabilities')).status).toBe(401); for (const roles of [['user'], ['admin'], ['admin.audit.read'], ['admin.playAudit.read:other:default']]) { expect((await get('capabilities', await token(roles))).status).toBe(403); } - const admin = await token([`admin.playAudit.read:${profileName}`]); expect(await db.general.findUnique({ where: { userId: auditUserId } })).toBeNull(); expect((await get('capabilities', admin)).body).toMatchObject({ result: { data: { read: true, accounts: false } }, @@ -2446,6 +2513,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.generalTurn.deleteMany({ where: { generalId, turnIdx: { in: [9001, 9002] } } }); await db.nation.deleteMany({ where: { id: { in: [99121, 99122] } } }); await db.worldState.update({ where: { id: fixtureWorldId }, diff --git a/app/game-frontend/e2e/playAudit.spec.ts b/app/game-frontend/e2e/playAudit.spec.ts index 0659dcd2..dcab57a8 100644 --- a/app/game-frontend/e2e/playAudit.spec.ts +++ b/app/game-frontend/e2e/playAudit.spec.ts @@ -151,6 +151,52 @@ const install = async (page: Page, denied = false) => { }, ], }); + case 'playAudit.generalDetail': + return result({ + ...world, + sample: input.at ?? null, + collected: true, + general: { ...general, name: input.at ? '과거감사장수' : general.name }, + nation: { id: 2, name: '촉' }, + city: { id: 3, name: '성도' }, + }); + case 'playAudit.cityDetail': + return result({ + ...world, + collected: true, + sample: input.at ?? null, + nation: { id: 2, name: '촉' }, + city: { + 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, + }, + }); + case 'playAudit.generalTurns': + return result({ + ...world, + currentOnly: true, + generalExists: true, + items: [{ turnIdx: 0, actionCode: '휴식', argumentJson: '{}' }], + nextCursor: null, + }); case 'playAudit.cities': return result({ ...world, @@ -316,3 +362,45 @@ test('final nation snapshot is separate from the monthly series', async ({ page }); await capture(page, 'final-nation'); }); + +test('selected general reads detail on demand and separates current reservations from history', async ({ page }) => { + const requests = await install(page); + await page.goto(gamePath('/play-audit?tab=generals')); + await expect(page.getByRole('button', { name: '감사장수 (#1)', exact: true })).toBeVisible(); + const before = requests.filter((request) => request.operation === 'playAudit.generals').length; + await page.getByRole('button', { name: '감사장수 (#1)', exact: true }).click(); + await expect(page.getByRole('heading', { name: '선택 장수 상세' })).toBeVisible(); + await expect(page.getByText('국가 촉 · 도시 성도 · 부대 #0', { exact: true })).toBeVisible(); + expect(requests.filter((request) => request.operation === 'playAudit.generals')).toHaveLength(before); + expect(requests.some((request) => request.operation === 'playAudit.generalTurns')).toBe(false); + await page.getByRole('button', { name: '현재 예약 명령 조회', exact: true }).click(); + await expect(page.getByText(/위치 0: 휴식/)).toBeVisible(); + await capture(page, 'general-detail'); + await page.getByRole('button', { name: '상세 닫기', exact: true }).click(); + await expect(page.getByRole('heading', { name: '선택 장수 상세' })).toHaveCount(0); + await page.goto(gamePath('/play-audit?tab=generals&general=1&at=month&year=190&month=6')); + await expect(page.getByRole('heading', { name: '과거감사장수 (#1)' })).toBeVisible(); + await expect(page.getByRole('button', { name: '현재 예약 명령 조회', exact: true })).toHaveCount(0); + expect(requests.filter((request) => request.operation === 'playAudit.generalTurns')).toHaveLength(1); +}); + +test('city detail is addressable without reloading the list and retains month for stationed generals', async ({ + page, +}) => { + await page.setViewportSize({ width: 390, height: 844 }); + const requests = await install(page); + await page.goto(gamePath('/play-audit?tab=cities&at=month&year=190&month=6')); + const button = page.getByRole('button', { name: '성도 (#3)', exact: true }); + await expect(button).toBeVisible(); + const before = requests.filter((request) => request.operation === 'playAudit.cities').length; + await button.click(); + await expect(page.getByRole('heading', { name: '성도 (#3) · 촉' })).toBeVisible(); + expect(requests.filter((request) => request.operation === 'playAudit.cities')).toHaveLength(before); + await capture(page, 'city-detail-mobile'); + await page.getByRole('button', { name: '이 시점의 모든 국가 주둔 장수', exact: true }).click(); + await expect(page.getByRole('rowheader', { name: /감사장수/ })).toBeVisible(); + expect(requests.filter((request) => request.operation === 'playAudit.generals').at(-1)?.input).toMatchObject({ + cityId: 3, + at: { year: 190, month: 6, kind: 'MONTH_END' }, + }); +}); diff --git a/app/game-frontend/src/components/playAudit/AuditCityDetail.vue b/app/game-frontend/src/components/playAudit/AuditCityDetail.vue new file mode 100644 index 00000000..b7f9d282 --- /dev/null +++ b/app/game-frontend/src/components/playAudit/AuditCityDetail.vue @@ -0,0 +1,114 @@ + + + + + diff --git a/app/game-frontend/src/components/playAudit/AuditGeneralDetail.vue b/app/game-frontend/src/components/playAudit/AuditGeneralDetail.vue new file mode 100644 index 00000000..07170ff9 --- /dev/null +++ b/app/game-frontend/src/components/playAudit/AuditGeneralDetail.vue @@ -0,0 +1,138 @@ + + + + + diff --git a/app/game-frontend/src/views/PlayAuditView.vue b/app/game-frontend/src/views/PlayAuditView.vue index 36abeb3f..d8950295 100644 --- a/app/game-frontend/src/views/PlayAuditView.vue +++ b/app/game-frontend/src/views/PlayAuditView.vue @@ -4,6 +4,8 @@ import { useRoute, useRouter } from 'vue-router'; import PanelCard from '../components/ui/PanelCard.vue'; import AuditNationSeries from '../components/playAudit/AuditNationSeries.vue'; import AuditNationSnapshot from '../components/playAudit/AuditNationSnapshot.vue'; +import AuditGeneralDetail from '../components/playAudit/AuditGeneralDetail.vue'; +import AuditCityDetail from '../components/playAudit/AuditCityDetail.vue'; import { usePageExit } from '../composables/usePageExit'; import { trpc } from '../utils/trpc'; @@ -39,6 +41,29 @@ const resolution = ref<'month' | 'halfYear'>('halfYear'); let generation = 0; const numeric = (value: unknown, fallback: number) => typeof value === 'string' && /^\d+$/.test(value) ? Number(value) : fallback; +const selectedGeneral = computed(() => + typeof route.query.general === 'string' && /^\d+$/.test(route.query.general) ? Number(route.query.general) : null +); +const selectedAt = computed(() => + route.query.at === 'month' || route.query.at === 'final' + ? { + year: numeric(route.query.year, coverage.value?.year ?? 0), + month: numeric(route.query.month, coverage.value?.month ?? 1), + kind: route.query.at === 'final' ? ('FINAL' as const) : ('MONTH_END' as const), + } + : undefined +); +const selectedCity = computed(() => + typeof route.query.cityRecord === 'string' && /^\d+$/.test(route.query.cityRecord) + ? Number(route.query.cityRecord) + : null +); +const selectGeneral = (id: number) => + router.push({ query: { ...route.query, general: String(id), cityRecord: undefined } }); +const closeGeneral = () => router.push({ query: { ...route.query, general: undefined } }); +const selectCity = (id: number) => + router.push({ query: { ...route.query, cityRecord: String(id), general: undefined } }); +const closeCity = () => router.push({ query: { ...route.query, cityRecord: undefined } }); const at = computed(() => moment.value === 'current' ? undefined @@ -189,6 +214,7 @@ const refresh = async () => { for (const response of results) if (response.status === 'rejected') error.value = message(response.reason); }; const showCityGenerals = async (id: number) => { + readQuery(); tab.value = 'generals'; cityId.value = String(id); nationId.value = ''; @@ -207,7 +233,7 @@ const moreNations = async () => { } }; watch( - () => route.fullPath, + () => JSON.stringify(Object.entries(route.query).filter(([key]) => key !== 'general' && key !== 'cityRecord')), () => { if (authorized.value) { readQuery(); @@ -369,7 +395,9 @@ onMounted(async () => { - {{ general.name }} (#{{ general.id }})
{{ +
{{ general.npcState < 2 ? '유저' : general.npcState === 5 ? '부대장 NPC' : 'NPC' }} @@ -435,7 +463,11 @@ onMounted(async () => { - {{ city.name }} (#{{ city.id }}) + + + {{ nationName(city.nationId) }} {{ format(city.population) }} / {{ format(city.populationMax) }} @@ -483,6 +515,19 @@ onMounted(async () => { 다시 조회 + + diff --git a/docs/design/play-audit-implementation.md b/docs/design/play-audit-implementation.md index f1d2e883..d9f268fd 100644 --- a/docs/design/play-audit-implementation.md +++ b/docs/design/play-audit-implementation.md @@ -46,6 +46,19 @@ game session transfer를 사용하고 Gateway가 감사 데이터를 대신 읽 FINAL의 관측값을 월말/반기 합계에 추가하지 않는다. 표본 없음과 해당 국가 없음도 구분한다. 최종 수집 자체의 모든 게임 종료 경로 연결은 P1의 남은 lifecycle 검증을 따른다. +`generalDetail/cityDetail`은 선택 엔티티와 관련 국가·도시의 PK만 조회한다. +현재/과거 DTO를 분리하고 과거 이름·위치 이름도 같은 표본에서 읽는다. 현재 행으로 +과거의 누락을 메우지 않는다. 목록에서 이름을 누르면 URL의 `general/cityRecord`로 +상세를 연다. 상세 열기/닫기는 목록 조회 조건에서 제외하여 목록을 다시 읽지 않는다. +도시 상세의 주둔 연결도 적용된 월을 유지한다. + +`generalTurns`는 현재 예약 전용 별도 조회이며 과거 시점을 입력받지 않는다. +상세의 버튼을 눌렀을 때만 기본50/최대200, `turnIdx` cursor로 읽는다. 정상30 slot을 +넘은 잘못된 값도 숨기지 않는다. 인자는 읽기 전용 `argumentJson` 텍스트로 반환한다. +범용 JSON의 재귀 타입을 UI에 그대로 전달하지 않으면서 값은 생략하지 않는다. +예약 조회 실패는 장수 상세를 지우지 않는다. 과거 예약 변경은 이후 사건 원장이 담당하며 +현재 큐에서 복원한 것처럼 표시하지 않는다. 전투 요약/로그와 지도·검색/정렬은 남는다. + `app/game-engine/src/playAudit/snapshot.ts`는 기존 메모리 엔티티에서 명시적으로 허용한 장수·도시 필드와 국가별 자원·숙련 집계를 만든다. 입력 iterable을 각각 한 번 순회하며 국가마다 장수 목록을 다시 검색하지 않는다. 장수의 stats/role/items도