플레이 감사 장수 이름 검색과 번호 역순 조회 추가

This commit is contained in:
2026-09-16 07:32:29 +00:00
parent 52cea882ab
commit 2f5036ea6b
6 changed files with 122 additions and 7 deletions
+15 -4
View File
@@ -154,6 +154,8 @@ export const playAuditRouter = router({
zAuditPage.extend({
cityId: z.number().int().nonnegative().optional(),
population: z.enum(['human', 'npc', 'troopNpc']).optional(),
name: z.string().trim().max(64).optional(),
order: z.enum(['asc', 'desc']).default('asc'),
})
)
.query(({ ctx, input }) =>
@@ -168,6 +170,14 @@ export const playAuditRouter = router({
? 5
: undefined;
const filter = { nationId: input.nationId, cityId: input.cityId, npcState };
// LIKE wildcard도 이름의 문자로 취급한다. 과거 검색은 선택한 표본 안에서만 수행한다.
const name = input.name?.replace(/[\\%_]/g, '\\$&');
const idRange =
input.cursor === undefined
? undefined
: input.order === 'desc'
? { lt: input.cursor }
: { gt: input.cursor };
if (input.at) {
const sample = await findAuditMonth(tx, world, input.at);
const rows = sample
@@ -175,9 +185,10 @@ export const playAuditRouter = router({
where: {
sampleId: sample.id,
...filter,
generalId: input.cursor === undefined ? undefined : { gt: input.cursor },
generalId: idRange,
data: name ? { path: ['name'], string_contains: name } : undefined,
},
orderBy: { generalId: 'asc' },
orderBy: { generalId: input.order },
take: input.limit + 1,
select: { data: true },
})
@@ -194,8 +205,8 @@ export const playAuditRouter = router({
};
}
const rows = await tx.general.findMany({
where: { ...filter, id: input.cursor === undefined ? undefined : { gt: input.cursor } },
orderBy: { id: 'asc' },
where: { ...filter, id: idRange, name: name ? { contains: name } : undefined },
orderBy: { id: input.order },
take: input.limit + 1,
select: generalSelect,
});
@@ -2628,6 +2628,43 @@ integration('game API security over HTTP transport', () => {
result: { data: { collected: true, items: [{ name: '과거이름' }] } },
});
expect(JSON.stringify(history.body)).not.toContain('must-not-expose');
for (const name of ['과거', ' 과거 ']) {
expect((await get('generals', admin, { at: { year: 190, month: 1 }, name })).body).toMatchObject({
result: { data: { items: [{ name: '과거이름' }] } },
});
}
for (const name of ['없는이름', '%', '_', '\\']) {
expect((await get('generals', admin, { at: { year: 190, month: 1 }, name })).body).toMatchObject({
result: { data: { items: [] } },
});
}
const currentName = (await db.general.findUniqueOrThrow({ where: { id: generalId } })).name;
expect((await get('generals', admin, { name: currentName })).body).toMatchObject({
result: { data: { items: expect.arrayContaining([expect.objectContaining({ id: generalId })]) } },
});
expect((await get('generals', admin, { name: '과거이름' })).body).toMatchObject({
result: { data: { items: [] } },
});
expect((await get('generals', admin, { name: '%' })).body).toMatchObject({
result: { data: { items: [] } },
});
const descendingIds = await db.general.findMany({ orderBy: { id: 'desc' }, select: { id: true } });
expect((await get('generals', admin, { order: 'desc', limit: 1 })).body).toMatchObject({
result: { data: { nextCursor: descendingIds[0]!.id, items: [descendingIds[0]] } },
});
expect(
(await get('generals', admin, { order: 'desc', limit: 1, cursor: descendingIds[0]!.id })).body
).toMatchObject({
result: { data: { items: [descendingIds[1]] } },
});
expect(
(await get('generals', admin, { order: 'desc', at: { year: 190, month: 1 }, cursor: generalId })).body
).toMatchObject({
result: { data: { items: [] } },
});
expect((await get('generals', admin, { name: '가'.repeat(65) })).status).toBe(400);
expect((await get('generals', admin, { order: 'gold' })).status).toBe(400);
expect((await get('generals', admin, { at: { year: 190, month: 2 } })).body).toMatchObject({
result: { data: { collected: false, items: [] } },
});
+33
View File
@@ -982,3 +982,36 @@ for (const [kind, label, value] of [
await expect(page.getByLabel('외교 전후 값', { exact: true })).toContainText('미관측 / 없음');
});
}
test('general search is explicit and persists across pagination and reload', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
const requests = await install(page);
await page.goto(gamePath('/play-audit?tab=generals&at=month&year=190&month=6'));
await expect(page.getByRole('rowheader', { name: /감사장수/ })).toBeVisible();
const count = requests.filter((r) => r.operation === 'playAudit.generals').length;
await page.getByLabel('장수 이름', { exact: true }).fill('감사');
await page.getByLabel('장수 번호 정렬', { exact: true }).selectOption('desc');
expect(requests.filter((r) => r.operation === 'playAudit.generals')).toHaveLength(count);
await page.getByRole('button', { name: '조회', exact: true }).click();
await expect(page).toHaveURL(/name=/);
await expect
.poll(() => requests.filter((r) => r.operation === 'playAudit.generals').at(-1)?.input)
.toMatchObject({
name: '감사',
order: 'desc',
at: { year: 190, month: 6, kind: 'MONTH_END' },
});
await page.getByRole('button', { name: '다음 50개 불러오기' }).click();
await expect
.poll(() => requests.filter((r) => r.operation === 'playAudit.generals').at(-1)?.input)
.toMatchObject({
name: '감사',
order: 'desc',
cursor: 1,
});
await page.reload();
await expect(page.getByLabel('장수 이름', { exact: true })).toHaveValue('감사');
await expect(page.getByLabel('장수 번호 정렬', { exact: true })).toHaveValue('desc');
await expect(page.getByRole('rowheader', { name: /감사장수/ })).toBeVisible();
await capture(page, 'mobile-general-search');
});
@@ -63,6 +63,8 @@ const appliedDiplomacy = computed(() => ({
const nationId = ref('');
const cityId = ref('');
const population = ref('');
const generalName = ref('');
const generalOrder = ref<'asc' | 'desc'>('asc');
const moment = ref('current');
const year = ref(0);
const month = ref(1);
@@ -139,6 +141,8 @@ const readQuery = () => {
otherNationId.value = route.query.otherNation ? String(numeric(route.query.otherNation, 0)) : '';
nationId.value = route.query.nation ? String(numeric(route.query.nation, 0)) : '';
cityId.value = route.query.city ? String(numeric(route.query.city, 0)) : '';
generalName.value = typeof route.query.name === 'string' ? route.query.name : '';
generalOrder.value = route.query.order === 'desc' ? 'desc' : 'asc';
population.value = ['human', 'npc', 'troopNpc'].includes(String(route.query.population))
? String(route.query.population)
: '';
@@ -171,6 +175,8 @@ const load = async (append = false) => {
const response = await trpc.playAudit.generals.query({
...filter,
cityId: cityId.value === '' ? undefined : Number(cityId.value),
name: generalName.value.trim() || undefined,
order: generalOrder.value,
population:
population.value === 'human' || population.value === 'npc' || population.value === 'troopNpc'
? population.value
@@ -241,6 +247,8 @@ const apply = async () => {
otherNation: tab.value === 'diplomacy' ? otherNationId.value || undefined : undefined,
city: cityId.value || undefined,
population: population.value || undefined,
name: tab.value === 'generals' ? generalName.value.trim() || undefined : undefined,
order: tab.value === 'generals' ? generalOrder.value : undefined,
at: moment.value,
year: String(year.value),
month: String(month.value),
@@ -269,6 +277,8 @@ const showCityGenerals = async (id: number) => {
cityId.value = String(id);
nationId.value = '';
population.value = '';
generalName.value = '';
generalOrder.value = 'asc';
await apply();
};
const moreNations = async () => {
@@ -468,6 +478,19 @@ onMounted(async () => {
</select></label
>
<template v-if="tab === 'generals'">
<label
>장수 이름<input v-model="generalName" maxlength="64" placeholder="이름 부분 검색"
/></label>
<label
>장수 번호 정렬<select
class="legacy-sort-select"
v-model="generalOrder"
aria-label="장수 번호 정렬"
>
<option value="asc">오름차순</option>
<option value="desc">내림차순</option>
</select></label
>
<label>도시 번호<input v-model="cityId" type="number" min="0" placeholder="모든 도시" /></label>
<label
>장수 분류<select class="legacy-sort-select" v-model="population">
+10 -1
View File
@@ -20,6 +20,15 @@ migration head를 가리킨다. 실제 PG의55→56/빈56/no-op·기존 값/null
### 기본 조회 화면
장수 이름 부분 검색과 장수 번호 양방향 정렬을 현재/월말 모두 지원한다. 최대64자,
대소문자 구분, LIKE wildcard 문자 escape를 동일하게 적용한다. 입력 중에는 요청하지
않고 조회 버튼으로 URL에 적용하며, 더 보기와 새로고침도 같은 필터·정렬을 유지한다.
도시→모든 주둔 장수 연결에서는 이름 조건도 해제한다. 과거 검색은 sampleId로 먼저
좁힌 뒤 당시 JSON 이름을 검사하며 현재 이름을 참조하지 않는다. 역순은 ID `< cursor`
페이지를 잇는다. 자원·능력별 정렬은 복합 cursor와 비용 검토가 추가로 필요하다.
실제 PG120개월×1,000명 fixture에서 선택 월 PK1000행, 국가 추가 시50행으로 후보를
좁혔다. 이 한 fixture의 실행계획은 전체 COST gate나 운영 p95 증거를 대신하지 않는다.
프로필 game frontend의 `/play-audit`는 장수가 없는 감사 계정도 직접 접근한다.
`capabilities`가 허용된 뒤 coverage와 국가 목록을 읽고 선택한 조회만 요청한다.
권한 거부 시 다른 감사 자료를 미리 가져오지 않는다. URL에 탭·국가·도시·표본 월·기간을
@@ -40,7 +49,7 @@ PanelCard, legacy-button, legacy-sort-select를 재사용한다. 새 차트 라
이 화면은 Core 신규 UX다. 최대 폭 1200px, 390px 모바일에서 문서 가로 넘침 없음,
넓은 표만 내부 수평 스크롤, 공통 14px 기본 typography와 명시적 focus/disabled가 계약이다.
월말/FINAL 장수·도시 projection을 보여주지만 지도,
전투 통계, 검색·정렬은 후속 구현으로 남는다. 로그와 현재 예약 조회는 아래 구현을 따른다.
전투 통계, 자원·능력별 정렬은 후속 구현으로 남는다. 로그와 현재 예약 조회는 아래 구현을 따른다.
따라서 기본 화면 추가만으로 R1~R3/P2를 완료 처리하지 않는다.
Gateway 서버 관리의 프로필 카드에는 `admin.playAudit.read` capability의 해당 전체
+4 -2
View File
@@ -12,7 +12,7 @@
| 화면 | 사용할 수 있는 정보 | 읽을 때 주의할 점 |
| --- | --- | --- |
| 국가 | 월말 금·쌀·기술력, 세율·실제 정산, 유저/NPC/부대장 NPC별 자원·숙련 집계, 월/6개월 그래프 | 보유량은 마지막 월말, 수입/지급액은 기간 합. 부분 기간/미수집은 0과 다름 |
| 장수 | 모든 국가·재야의 현재/월말 장수, 자원·능력·숙련·병력·훈련·사기·장비·특기·위치, 독립 로그 상세 | 현재 예약은 현재 조회에서만 제공. 과거 월말은 그달 모든 명령의 이력이 아님 |
| 장수 | 이름 부분 검색·장수 번호 정렬, 모든 국가·재야의 현재/월말 장수, 자원·능력·숙련·병력·훈련·사기·장비·특기·위치, 독립 로그 상세 | 현재 예약은 현재 조회에서만 제공. 과거 월말은 그달 모든 명령의 이력이 아님 |
| 도시 | 현재/월말 소유·내정 상태와 국가별 주둔 장수 상세 연결 | 월말 주둔은 그달 모든 방문자가 아님. 지도 기반 탐색은 미완성 |
| 외교 | 국가쌍·기간별 문서 제안/승인/철회/파기, 즉시 합의와 월간·명령 관계 전이, 생성/소멸 관계 | 문서 내용과 실제 관계는 별개. 최초 관측 이전 사건은 복원하지 않음 |
| 정책 | NPC 국가 값·국가/장수 우선순위·국방 설정의 기준 버전과 변경 전후, 당시 주체 | 수뇌용 공개 화면과 NPC 결정의 정책 참조 연결은 아직 없음 |
@@ -20,6 +20,8 @@
목록/그래프와 선택 상세를 분리해 읽는다. 표는 기본50건이며 더 보기를 명시적으로
누른다. 현재 상태는 수동으로 조회하며 백그라운드 polling은 하지 않는다. 필터·월·선택
대상은 URL에 남으므로 같은 권한으로 직접 열기/새로고침할 수 있다.
장수 이름은 선택 시점의 이름으로 부분 검색하며 영문 대소문자를 구분한다. 검색어는
64자까지이고 `%`·`_`도 문자 그대로 찾는다. 장수 번호 정렬은 오름차순/내림차순을 제공한다.
## 진입과 권한
@@ -87,7 +89,7 @@ Gateway의 새 진입/권한 catalog도 사용하려면 같은 commit의 Gateway
- 계정/IP HMAC 조사, 상세 자원 이동, 예약 변경/실행 연결, 실패·rollback 조사와 알려진
버그 사례 조회는 미완성이다. 자동 탐지·자동 제재 기능도 제공하지 않는다.
- 일부 국가 생성/소멸 사건은 actor/request가 null이다. 원인을 현재 주체로 추정하지 않는다.
- 장수 이름 검색·자유 정렬·지도 탐색·모든 전투 지표/연결과 전체 COST gate는 남아 있다.
- 자원·능력별 정렬·지도 탐색·모든 전투 지표/연결과 전체 COST gate는 남아 있다.
- 격리 PostgreSQL/Redis 및 mock API를 쓰는 실제 Chromium 검증은 운영 HTTPS 검증과 다르다.
후속 NPC/행위/계정 저장소의 필드·순서·보존·인덱스 요구는 설계의 R5/조사 A~F와 비용