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 @@
+
+
+
+ 도시 조회 중… {{ error }} 선택한 시점의 표본이 없습니다. 선택한 시점에 해당 도시가 없습니다. 상세 조회 중… {{ error }} 선택한 시점의 표본이 없습니다. 선택한 시점에 해당 장수가 없습니다.
+ 국가 {{ data.nation?.name ?? `#${data.general.nationId}` }} · 도시
+ {{ data.city?.name ?? `#${data.general.cityId}` }} · 부대 #{{ data.general.troopId }}
+
+ 금 {{ format(data.general.gold) }} · 쌀 {{ format(data.general.rice) }} · 병력
+ {{ format(data.general.crew) }} · 훈련 {{ data.general.train }} · 사기 {{ data.general.atmos }}
+
+ 통솔 {{ data.general.stats.leadership }} · 무력 {{ data.general.stats.strength }} · 지력
+ {{ data.general.stats.intelligence }} · 경험 {{ format(data.general.experience) }} · 공헌
+ {{ format(data.general.dedication) }}
+ 숙련 (보 / 궁 / 기 / 귀 / 차): {{ Object.values(data.general.dex).map(format).join(' / ') }} 과거 예약 명령은 월말 표본에 포함되지 않습니다. 예약 조회 중… {{ turnsError }} 예약 조회 시각 {{ turns.asOf }} · tick {{ turns.tick ?? '없음' }} 현재 해당 장수가 없습니다. 저장된 예약 명령이 없습니다.
+ {{ data.city.name }} (#{{ data.city.id }}) ·
+ {{ data.nation?.name ?? `국가 #${data.city.nationId}` }}
+
+
+
+
+
+
+ {{ data.general.name }} (#{{ data.general.id }})
+
+
+
+
+
+
+ {{ turn.argumentJson }}
+