플레이 감사 장수 이름 검색과 번호 역순 조회 추가
This commit is contained in:
@@ -154,6 +154,8 @@ export const playAuditRouter = router({
|
|||||||
zAuditPage.extend({
|
zAuditPage.extend({
|
||||||
cityId: z.number().int().nonnegative().optional(),
|
cityId: z.number().int().nonnegative().optional(),
|
||||||
population: z.enum(['human', 'npc', 'troopNpc']).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 }) =>
|
.query(({ ctx, input }) =>
|
||||||
@@ -168,6 +170,14 @@ export const playAuditRouter = router({
|
|||||||
? 5
|
? 5
|
||||||
: undefined;
|
: undefined;
|
||||||
const filter = { nationId: input.nationId, cityId: input.cityId, npcState };
|
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) {
|
if (input.at) {
|
||||||
const sample = await findAuditMonth(tx, world, input.at);
|
const sample = await findAuditMonth(tx, world, input.at);
|
||||||
const rows = sample
|
const rows = sample
|
||||||
@@ -175,9 +185,10 @@ export const playAuditRouter = router({
|
|||||||
where: {
|
where: {
|
||||||
sampleId: sample.id,
|
sampleId: sample.id,
|
||||||
...filter,
|
...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,
|
take: input.limit + 1,
|
||||||
select: { data: true },
|
select: { data: true },
|
||||||
})
|
})
|
||||||
@@ -194,8 +205,8 @@ export const playAuditRouter = router({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
const rows = await tx.general.findMany({
|
const rows = await tx.general.findMany({
|
||||||
where: { ...filter, id: input.cursor === undefined ? undefined : { gt: input.cursor } },
|
where: { ...filter, id: idRange, name: name ? { contains: name } : undefined },
|
||||||
orderBy: { id: 'asc' },
|
orderBy: { id: input.order },
|
||||||
take: input.limit + 1,
|
take: input.limit + 1,
|
||||||
select: generalSelect,
|
select: generalSelect,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2628,6 +2628,43 @@ integration('game API security over HTTP transport', () => {
|
|||||||
result: { data: { collected: true, items: [{ name: '과거이름' }] } },
|
result: { data: { collected: true, items: [{ name: '과거이름' }] } },
|
||||||
});
|
});
|
||||||
expect(JSON.stringify(history.body)).not.toContain('must-not-expose');
|
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({
|
expect((await get('generals', admin, { at: { year: 190, month: 2 } })).body).toMatchObject({
|
||||||
result: { data: { collected: false, items: [] } },
|
result: { data: { collected: false, items: [] } },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -982,3 +982,36 @@ for (const [kind, label, value] of [
|
|||||||
await expect(page.getByLabel('외교 전후 값', { exact: true })).toContainText('미관측 / 없음');
|
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 nationId = ref('');
|
||||||
const cityId = ref('');
|
const cityId = ref('');
|
||||||
const population = ref('');
|
const population = ref('');
|
||||||
|
const generalName = ref('');
|
||||||
|
const generalOrder = ref<'asc' | 'desc'>('asc');
|
||||||
const moment = ref('current');
|
const moment = ref('current');
|
||||||
const year = ref(0);
|
const year = ref(0);
|
||||||
const month = ref(1);
|
const month = ref(1);
|
||||||
@@ -139,6 +141,8 @@ const readQuery = () => {
|
|||||||
otherNationId.value = route.query.otherNation ? String(numeric(route.query.otherNation, 0)) : '';
|
otherNationId.value = route.query.otherNation ? String(numeric(route.query.otherNation, 0)) : '';
|
||||||
nationId.value = route.query.nation ? String(numeric(route.query.nation, 0)) : '';
|
nationId.value = route.query.nation ? String(numeric(route.query.nation, 0)) : '';
|
||||||
cityId.value = route.query.city ? String(numeric(route.query.city, 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))
|
population.value = ['human', 'npc', 'troopNpc'].includes(String(route.query.population))
|
||||||
? String(route.query.population)
|
? String(route.query.population)
|
||||||
: '';
|
: '';
|
||||||
@@ -171,6 +175,8 @@ const load = async (append = false) => {
|
|||||||
const response = await trpc.playAudit.generals.query({
|
const response = await trpc.playAudit.generals.query({
|
||||||
...filter,
|
...filter,
|
||||||
cityId: cityId.value === '' ? undefined : Number(cityId.value),
|
cityId: cityId.value === '' ? undefined : Number(cityId.value),
|
||||||
|
name: generalName.value.trim() || undefined,
|
||||||
|
order: generalOrder.value,
|
||||||
population:
|
population:
|
||||||
population.value === 'human' || population.value === 'npc' || population.value === 'troopNpc'
|
population.value === 'human' || population.value === 'npc' || population.value === 'troopNpc'
|
||||||
? population.value
|
? population.value
|
||||||
@@ -241,6 +247,8 @@ const apply = async () => {
|
|||||||
otherNation: tab.value === 'diplomacy' ? otherNationId.value || undefined : undefined,
|
otherNation: tab.value === 'diplomacy' ? otherNationId.value || undefined : undefined,
|
||||||
city: cityId.value || undefined,
|
city: cityId.value || undefined,
|
||||||
population: population.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,
|
at: moment.value,
|
||||||
year: String(year.value),
|
year: String(year.value),
|
||||||
month: String(month.value),
|
month: String(month.value),
|
||||||
@@ -269,6 +277,8 @@ const showCityGenerals = async (id: number) => {
|
|||||||
cityId.value = String(id);
|
cityId.value = String(id);
|
||||||
nationId.value = '';
|
nationId.value = '';
|
||||||
population.value = '';
|
population.value = '';
|
||||||
|
generalName.value = '';
|
||||||
|
generalOrder.value = 'asc';
|
||||||
await apply();
|
await apply();
|
||||||
};
|
};
|
||||||
const moreNations = async () => {
|
const moreNations = async () => {
|
||||||
@@ -468,6 +478,19 @@ onMounted(async () => {
|
|||||||
</select></label
|
</select></label
|
||||||
>
|
>
|
||||||
<template v-if="tab === 'generals'">
|
<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>도시 번호<input v-model="cityId" type="number" min="0" placeholder="모든 도시" /></label>
|
||||||
<label
|
<label
|
||||||
>장수 분류<select class="legacy-sort-select" v-model="population">
|
>장수 분류<select class="legacy-sort-select" v-model="population">
|
||||||
|
|||||||
@@ -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`는 장수가 없는 감사 계정도 직접 접근한다.
|
프로필 game frontend의 `/play-audit`는 장수가 없는 감사 계정도 직접 접근한다.
|
||||||
`capabilities`가 허용된 뒤 coverage와 국가 목록을 읽고 선택한 조회만 요청한다.
|
`capabilities`가 허용된 뒤 coverage와 국가 목록을 읽고 선택한 조회만 요청한다.
|
||||||
권한 거부 시 다른 감사 자료를 미리 가져오지 않는다. URL에 탭·국가·도시·표본 월·기간을
|
권한 거부 시 다른 감사 자료를 미리 가져오지 않는다. URL에 탭·국가·도시·표본 월·기간을
|
||||||
@@ -40,7 +49,7 @@ PanelCard, legacy-button, legacy-sort-select를 재사용한다. 새 차트 라
|
|||||||
이 화면은 Core 신규 UX다. 최대 폭 1200px, 390px 모바일에서 문서 가로 넘침 없음,
|
이 화면은 Core 신규 UX다. 최대 폭 1200px, 390px 모바일에서 문서 가로 넘침 없음,
|
||||||
넓은 표만 내부 수평 스크롤, 공통 14px 기본 typography와 명시적 focus/disabled가 계약이다.
|
넓은 표만 내부 수평 스크롤, 공통 14px 기본 typography와 명시적 focus/disabled가 계약이다.
|
||||||
월말/FINAL 장수·도시 projection을 보여주지만 지도,
|
월말/FINAL 장수·도시 projection을 보여주지만 지도,
|
||||||
전투 통계, 검색·정렬은 후속 구현으로 남는다. 로그와 현재 예약 조회는 아래 구현을 따른다.
|
전투 통계, 자원·능력별 정렬은 후속 구현으로 남는다. 로그와 현재 예약 조회는 아래 구현을 따른다.
|
||||||
따라서 기본 화면 추가만으로 R1~R3/P2를 완료 처리하지 않는다.
|
따라서 기본 화면 추가만으로 R1~R3/P2를 완료 처리하지 않는다.
|
||||||
|
|
||||||
Gateway 서버 관리의 프로필 카드에는 `admin.playAudit.read` capability의 해당 전체
|
Gateway 서버 관리의 프로필 카드에는 `admin.playAudit.read` capability의 해당 전체
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
| 화면 | 사용할 수 있는 정보 | 읽을 때 주의할 점 |
|
| 화면 | 사용할 수 있는 정보 | 읽을 때 주의할 점 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 국가 | 월말 금·쌀·기술력, 세율·실제 정산, 유저/NPC/부대장 NPC별 자원·숙련 집계, 월/6개월 그래프 | 보유량은 마지막 월말, 수입/지급액은 기간 합. 부분 기간/미수집은 0과 다름 |
|
| 국가 | 월말 금·쌀·기술력, 세율·실제 정산, 유저/NPC/부대장 NPC별 자원·숙련 집계, 월/6개월 그래프 | 보유량은 마지막 월말, 수입/지급액은 기간 합. 부분 기간/미수집은 0과 다름 |
|
||||||
| 장수 | 모든 국가·재야의 현재/월말 장수, 자원·능력·숙련·병력·훈련·사기·장비·특기·위치, 독립 로그 상세 | 현재 예약은 현재 조회에서만 제공. 과거 월말은 그달 모든 명령의 이력이 아님 |
|
| 장수 | 이름 부분 검색·장수 번호 정렬, 모든 국가·재야의 현재/월말 장수, 자원·능력·숙련·병력·훈련·사기·장비·특기·위치, 독립 로그 상세 | 현재 예약은 현재 조회에서만 제공. 과거 월말은 그달 모든 명령의 이력이 아님 |
|
||||||
| 도시 | 현재/월말 소유·내정 상태와 국가별 주둔 장수 상세 연결 | 월말 주둔은 그달 모든 방문자가 아님. 지도 기반 탐색은 미완성 |
|
| 도시 | 현재/월말 소유·내정 상태와 국가별 주둔 장수 상세 연결 | 월말 주둔은 그달 모든 방문자가 아님. 지도 기반 탐색은 미완성 |
|
||||||
| 외교 | 국가쌍·기간별 문서 제안/승인/철회/파기, 즉시 합의와 월간·명령 관계 전이, 생성/소멸 관계 | 문서 내용과 실제 관계는 별개. 최초 관측 이전 사건은 복원하지 않음 |
|
| 외교 | 국가쌍·기간별 문서 제안/승인/철회/파기, 즉시 합의와 월간·명령 관계 전이, 생성/소멸 관계 | 문서 내용과 실제 관계는 별개. 최초 관측 이전 사건은 복원하지 않음 |
|
||||||
| 정책 | NPC 국가 값·국가/장수 우선순위·국방 설정의 기준 버전과 변경 전후, 당시 주체 | 수뇌용 공개 화면과 NPC 결정의 정책 참조 연결은 아직 없음 |
|
| 정책 | NPC 국가 값·국가/장수 우선순위·국방 설정의 기준 버전과 변경 전후, 당시 주체 | 수뇌용 공개 화면과 NPC 결정의 정책 참조 연결은 아직 없음 |
|
||||||
@@ -20,6 +20,8 @@
|
|||||||
목록/그래프와 선택 상세를 분리해 읽는다. 표는 기본50건이며 더 보기를 명시적으로
|
목록/그래프와 선택 상세를 분리해 읽는다. 표는 기본50건이며 더 보기를 명시적으로
|
||||||
누른다. 현재 상태는 수동으로 조회하며 백그라운드 polling은 하지 않는다. 필터·월·선택
|
누른다. 현재 상태는 수동으로 조회하며 백그라운드 polling은 하지 않는다. 필터·월·선택
|
||||||
대상은 URL에 남으므로 같은 권한으로 직접 열기/새로고침할 수 있다.
|
대상은 URL에 남으므로 같은 권한으로 직접 열기/새로고침할 수 있다.
|
||||||
|
장수 이름은 선택 시점의 이름으로 부분 검색하며 영문 대소문자를 구분한다. 검색어는
|
||||||
|
64자까지이고 `%`·`_`도 문자 그대로 찾는다. 장수 번호 정렬은 오름차순/내림차순을 제공한다.
|
||||||
|
|
||||||
## 진입과 권한
|
## 진입과 권한
|
||||||
|
|
||||||
@@ -87,7 +89,7 @@ Gateway의 새 진입/권한 catalog도 사용하려면 같은 commit의 Gateway
|
|||||||
- 계정/IP HMAC 조사, 상세 자원 이동, 예약 변경/실행 연결, 실패·rollback 조사와 알려진
|
- 계정/IP HMAC 조사, 상세 자원 이동, 예약 변경/실행 연결, 실패·rollback 조사와 알려진
|
||||||
버그 사례 조회는 미완성이다. 자동 탐지·자동 제재 기능도 제공하지 않는다.
|
버그 사례 조회는 미완성이다. 자동 탐지·자동 제재 기능도 제공하지 않는다.
|
||||||
- 일부 국가 생성/소멸 사건은 actor/request가 null이다. 원인을 현재 주체로 추정하지 않는다.
|
- 일부 국가 생성/소멸 사건은 actor/request가 null이다. 원인을 현재 주체로 추정하지 않는다.
|
||||||
- 장수 이름 검색·자유 정렬·지도 탐색·모든 전투 지표/연결과 전체 COST gate는 남아 있다.
|
- 자원·능력별 정렬·지도 탐색·모든 전투 지표/연결과 전체 COST gate는 남아 있다.
|
||||||
- 격리 PostgreSQL/Redis 및 mock API를 쓰는 실제 Chromium 검증은 운영 HTTPS 검증과 다르다.
|
- 격리 PostgreSQL/Redis 및 mock API를 쓰는 실제 Chromium 검증은 운영 HTTPS 검증과 다르다.
|
||||||
|
|
||||||
후속 NPC/행위/계정 저장소의 필드·순서·보존·인덱스 요구는 설계의 R5/조사 A~F와 비용
|
후속 NPC/행위/계정 저장소의 필드·순서·보존·인덱스 요구는 설계의 R5/조사 A~F와 비용
|
||||||
|
|||||||
Reference in New Issue
Block a user