feat: 프로필 감사 화면에서 정책 버전과 전후 값을 조회

This commit is contained in:
2026-09-16 05:02:32 +00:00
parent d549cbf887
commit 3d60e6122d
8 changed files with 845 additions and 11 deletions
+172
View File
@@ -88,6 +88,72 @@ const install = async (page: Page, denied = false) => {
],
nextCursor: null,
});
case 'playAudit.policyHistory':
return result({
...world,
coverage: 'RECORDED_VERSIONS_ONLY',
items: [
{
id: String(input.cursor ? 'a' : 'b').repeat(64),
nationId: 2,
area: input.area,
revision: input.cursor ? 1 : 2,
source: input.cursor ? 'BASELINE' : 'CHANGE',
year: 190,
month: 6,
previousId: input.cursor ? null : 'a'.repeat(64),
actor: input.cursor
? null
: {
generalId: 1,
name: '당시군주',
nationId: 2,
officerLevel: 12,
npcState: 0,
},
createdAt: world.asOf,
},
],
nextCursor: input.cursor ? null : 2,
});
case 'playAudit.policyVersion': {
const baseline = input.id === 'a'.repeat(64);
return result({
...world,
version: {
id: input.id,
nationId: 2,
area: 'DEFENCE',
revision: baseline ? 1 : 2,
source: baseline ? 'BASELINE' : 'CHANGE',
year: 190,
month: 6,
previousId: baseline ? null : 'a'.repeat(64),
actor: baseline
? null
: { generalId: 1, name: '당시군주', nationId: 2, officerLevel: 12, npcState: 0 },
createdAt: world.asOf,
tick: '100',
ordinal: baseline ? 1 : 2,
requestId: baseline ? null : 'policy-request-fixture',
inputSequence: baseline ? null : '9007199254740993',
fields: [
{
key: 'scout',
beforeJson: baseline ? null : '0',
afterJson: '1',
changed: !baseline,
},
{
key: 'priority',
beforeJson: baseline ? null : 'null',
afterJson: '["<script>window.auditInjected=true</script>"]',
changed: !baseline,
},
],
},
});
}
case 'playAudit.coverage':
return result({ ...world, status: 'COLLECTED', samples: [], nextCursor: null });
case 'playAudit.nations':
@@ -510,3 +576,109 @@ test('initial calendar before the scenario year bounds default periods and month
.poll(() => requests.find((r) => r.operation === 'playAudit.nationSeries')?.input)
.toMatchObject({ from: { year: 189, month: 10 }, to: { year: 189, month: 10 } });
});
test('policy history reads summaries and selected versions only, preserving deep links and pagination', async ({
page,
}) => {
const requests = await install(page);
const path = '/play-audit?tab=policies&nation=2&policyArea=DEFENCE&fromYear=190&fromMonth=1&year=190&month=6';
await page.goto(gamePath(path));
await expect(page.getByRole('button', { name: '버전 2', exact: true })).toBeVisible();
expect(
requests.some(({ operation }) =>
['playAudit.policyVersion', 'playAudit.nationSeries', 'playAudit.generals'].includes(operation)
)
).toBe(false);
const listReads = requests.filter(({ operation }) => operation === 'playAudit.policyHistory').length;
const nationReads = requests.filter(({ operation }) => operation === 'playAudit.nations').length;
await page.getByRole('button', { name: '버전 2', exact: true }).click();
await expect(page.getByText('임관 권유 설정 (변경)', { exact: true })).toBeVisible();
await expect(page.getByText(/국가 #2 · 버전 2/)).toBeVisible();
expect(requests.filter(({ operation }) => operation === 'playAudit.policyHistory')).toHaveLength(listReads);
expect(requests.filter(({ operation }) => operation === 'playAudit.nations')).toHaveLength(nationReads);
await page.getByText('요청 연결', { exact: true }).click();
await expect(page.getByText('입력 순번 9007199254740993', { exact: true })).toBeVisible();
expect(await page.evaluate(() => Object.hasOwn(window, 'auditInjected'))).toBe(false);
await capture(page, 'desktop-policy-detail');
await page.reload();
await expect(page.getByText(/국가 #2 · 버전 2/)).toBeVisible();
await page.getByRole('button', { name: '이전 정책 버전', exact: true }).click();
await expect(page.getByText(/국가 #2 · 버전 1/)).toBeVisible();
await expect(page.getByRole('cell', { name: '관측하지 않음', exact: true })).toHaveCount(2);
await page.goBack();
await expect(page.getByText(/국가 #2 · 버전 2/)).toBeVisible();
await page.setViewportSize({ width: 390, height: 844 });
await capture(page, 'mobile-policy-detail');
await page.getByRole('button', { name: '정책 상세 닫기' }).click();
await expect(page.getByRole('heading', { name: /선택 정책 버전/ })).toHaveCount(0);
await page.getByRole('button', { name: '다음 정책 50개' }).click();
await expect(page.getByRole('button', { name: '버전 1', exact: true })).toBeVisible();
expect(requests.filter(({ operation }) => operation === 'playAudit.policyHistory').at(-1)?.input).toMatchObject({
area: 'DEFENCE',
cursor: 2,
nationId: 2,
});
await page.getByLabel('정책 영역').selectOption('NPC_GENERAL_PRIORITY');
const before = requests.filter(({ operation }) => operation === 'playAudit.policyHistory').length;
await page.getByRole('button', { name: '조회', exact: true }).click();
await expect
.poll(() => requests.filter(({ operation }) => operation === 'playAudit.policyHistory').length)
.toBeGreaterThan(before);
expect(requests.filter(({ operation }) => operation === 'playAudit.policyHistory').at(-1)?.input.area).toBe(
'NPC_GENERAL_PRIORITY'
);
});
test('policy detail failure retries independently without reloading its history', async ({ page }) => {
const requests = await install(page);
let fail = true;
await page.route(gameTrpcRoute, async (route) => {
if (fail && route.request().url().includes('playAudit.policyVersion')) {
fail = false;
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([
{
error: {
message: '정책 버전 일시 오류',
code: -32603,
data: { code: 'INTERNAL_SERVER_ERROR', httpStatus: 500 },
},
},
]),
});
return;
}
await route.fallback();
});
await page.goto(
gamePath('/play-audit?tab=policies&nation=2&policyArea=DEFENCE&fromYear=190&fromMonth=1&year=190&month=6')
);
await page.getByRole('button', { name: '버전 2', exact: true }).click();
await expect(page.getByRole('alert')).toContainText('정책 버전 일시 오류');
await expect(page.getByRole('button', { name: '버전 2', exact: true })).toBeVisible();
const count = requests.filter(({ operation }) => operation === 'playAudit.policyHistory').length;
await page.getByRole('button', { name: '버전 다시 조회', exact: true }).click();
await expect(page.getByText(/국가 #2 · 버전 2/)).toBeVisible();
expect(requests.filter(({ operation }) => operation === 'playAudit.policyHistory')).toHaveLength(count);
});
test('policy filter drafts do not read until applied, including default dates', async ({ page }) => {
const requests = await install(page);
await page.goto(gamePath('/play-audit?tab=policies&nation=2'));
await expect(page.getByRole('button', { name: '버전 2', exact: true })).toBeVisible();
const count = requests.filter(({ operation }) => operation === 'playAudit.policyHistory').length;
await page.getByLabel('시작 월', { exact: true }).fill('3');
await page.getByLabel('정책 영역').selectOption('DEFENCE');
await page.getByRole('button', { name: '조회', exact: true }).focus();
expect(requests.filter(({ operation }) => operation === 'playAudit.policyHistory')).toHaveLength(count);
await page.getByRole('button', { name: '조회', exact: true }).click();
await expect
.poll(() => requests.filter(({ operation }) => operation === 'playAudit.policyHistory').length)
.toBe(count + 1);
expect(requests.filter(({ operation }) => operation === 'playAudit.policyHistory').at(-1)?.input).toMatchObject({
area: 'DEFENCE',
from: { year: 190, month: 3 },
});
});
@@ -0,0 +1,256 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { trpc } from '../../utils/trpc';
const props = defineProps<{
nationId: number;
area: 'NPC_VALUES' | 'NPC_NATION_PRIORITY' | 'NPC_GENERAL_PRIORITY' | 'DEFENCE';
from: { year: number; month: number };
to: { year: number; month: number };
}>();
type History = Awaited<ReturnType<typeof trpc.playAudit.policyHistory.query>>;
type Detail = Awaited<ReturnType<typeof trpc.playAudit.policyVersion.query>>;
const route = useRoute();
const router = useRouter();
const data = ref<History | null>(null);
const detail = ref<Detail | null>(null);
const error = ref('');
const detailError = ref('');
const loading = ref(false);
const detailLoading = ref(false);
let generation = 0;
let detailGeneration = 0;
const selected = computed(() => (typeof route.query.policy === 'string' ? route.query.policy : null));
const fieldLabels: Record<string, string> = {
reqNationGold: '국가 권장 금',
reqNationRice: '국가 권장 쌀',
reqHumanWarUrgentGold: '유저전투장 긴급포상 금',
reqHumanWarUrgentRice: '유저전투장 긴급포상 쌀',
reqHumanWarRecommandGold: '유저전투장 권장 금',
reqHumanWarRecommandRice: '유저전투장 권장 쌀',
reqHumanDevelGold: '유저내정장 권장 금',
reqHumanDevelRice: '유저내정장 권장 쌀',
reqNPCWarGold: 'NPC전투장 권장 금',
reqNPCWarRice: 'NPC전투장 권장 쌀',
reqNPCDevelGold: 'NPC내정장 권장 금',
reqNPCDevelRice: 'NPC내정장 권장 쌀',
minimumResourceActionAmount: '포상/몰수/헌납/삼/팜 최소 단위',
maximumResourceActionAmount: '포상/몰수/헌납/삼/팜 최대 단위',
minWarCrew: '최소 전투 가능 병력 수',
minNPCRecruitCityPopulation: 'NPC 최소 징병 가능 인구 수',
safeRecruitCityPopulationRatio: '제자리 징병 허용 인구율 (비율)',
minNPCWarLeadership: 'NPC 전투 참여 통솔 기준',
properWarTrainAtmos: '훈련/사기진작 목표치',
cureThreshold: '요양 기준',
CombatForce: '전투 부대 편성',
SupportForce: '지원 부대 편성',
DevelopForce: '내정 부대 편성',
priority: '행동 우선순위',
war: '전쟁 금지 설정',
scout: '임관 권유 설정',
secretlimit: '기밀 공개 기준 (년)',
};
const labels = { BASELINE: '최초 관측', CHANGE: '실제 변경', OBSERVED_GAP: '관측 누락 이후 기준' };
const message = (cause: unknown) => (cause instanceof Error ? cause.message : '정책 이력을 조회하지 못했습니다.');
const load = async (append = false) => {
const request = ++generation;
loading.value = true;
error.value = '';
if (!append) data.value = null;
try {
const response = await trpc.playAudit.policyHistory.query({
...props,
limit: 50,
cursor: append ? (data.value?.nextCursor ?? undefined) : undefined,
});
if (request === generation)
data.value = {
...response,
items: append ? [...(data.value?.items ?? []), ...response.items] : response.items,
};
} catch (cause) {
if (request === generation) error.value = message(cause);
} finally {
if (request === generation) loading.value = false;
}
};
const loadDetail = async () => {
const request = ++detailGeneration;
detail.value = null;
detailError.value = '';
detailLoading.value = false;
if (!selected.value) return;
detailLoading.value = true;
try {
const response = await trpc.playAudit.policyVersion.query({ id: selected.value });
if (request === detailGeneration) detail.value = response;
} catch (cause) {
if (request === detailGeneration) detailError.value = message(cause);
} finally {
if (request === detailGeneration) detailLoading.value = false;
}
};
const select = (id: string | null) => router.push({ query: { ...route.query, policy: id ?? undefined } });
watch(
[
() => props.nationId,
() => props.area,
() => props.from.year,
() => props.from.month,
() => props.to.year,
() => props.to.month,
],
() => {
void load();
},
{ immediate: true }
);
watch(
selected,
() => {
void loadDetail();
},
{ immediate: true }
);
</script>
<template>
<section aria-label="정책 변경 이력">
<p>설정된 정책의 변경 이력입니다. 최초 관측 이전의 변경은 복원하지 않습니다.</p>
<p v-if="loading" role="status">정책 이력 조회 </p>
<p v-if="error" role="alert">{{ error }} <button class="legacy-button" @click="load()">다시 조회</button></p>
<template v-if="data">
<p v-if="data.coverage === 'IDENTITY_MISSING'">기수 식별자가 없어 정책 이력을 조회할 없습니다.</p>
<p v-else-if="!data.items.length">선택한 기간에 기록된 버전이 없습니다. 변경이 없었다는 뜻은 아닙니다.</p>
<div v-else class="table-scroll" tabindex="0" aria-label="정책 버전 목록">
<table>
<thead>
<tr>
<th>버전</th>
<th>게임 시각</th>
<th>종류</th>
<th>당시 변경 주체</th>
</tr>
</thead>
<tbody>
<tr v-for="item in data.items" :key="item.id">
<th scope="row">
<button class="legacy-button" @click="select(item.id)">버전 {{ item.revision }}</button>
</th>
<td>{{ item.year }} {{ item.month }}</td>
<td>{{ labels[item.source] }}</td>
<td v-if="item.actor">
{{ item.actor.name }} (#{{ item.actor.generalId }}) · 국가 #{{ item.actor.nationId }} ·
직책 {{ item.actor.officerLevel }}
</td>
<td v-else>관측 기준 · 변경 주체 미상</td>
</tr>
</tbody>
</table>
</div>
<button v-if="data.nextCursor !== null" class="legacy-button" :disabled="loading" @click="load(true)">
다음 정책 50개
</button>
</template>
<section v-if="selected" aria-label="선택 정책 버전">
<h3>선택 정책 버전 <button class="legacy-button" @click="select(null)">정책 상세 닫기</button></h3>
<p v-if="detailLoading" role="status">정책 버전 조회 </p>
<p v-if="detailError" role="alert">
{{ detailError }} <button class="legacy-button" @click="loadDetail">버전 다시 조회</button>
</p>
<template v-if="detail">
<p>
국가 #{{ detail.version.nationId }} · 버전 {{ detail.version.revision }} ·
{{ labels[detail.version.source] }} · {{ detail.version.year }} {{ detail.version.month }}
</p>
<p>
기록 시각 {{ detail.version.createdAt }} · tick {{ detail.version.tick ?? '미상' }} · 순번
{{ detail.version.ordinal }}
</p>
<p v-if="detail.version.actor">
{{ detail.version.actor.name }} (#{{ detail.version.actor.generalId }}) · 당시 국가 #{{
detail.version.actor.nationId
}}
· 직책 {{ detail.version.actor.officerLevel }}
</p>
<p v-if="detail.version.source !== 'CHANGE'">
버전은 관측 기준입니다. 이전 값과 변경 주체를 추정하지 않습니다.
</p>
<p>
null은 개별 설정이 없음을 뜻합니다. 설정값을 기록하며 당시 NPC별 유효 값은 여기서 재계산하지
않습니다.
</p>
<div class="table-scroll" tabindex="0" aria-label="정책 전후 ">
<table>
<thead>
<tr>
<th>설정</th>
<th>변경 </th>
<th>변경 </th>
</tr>
</thead>
<tbody>
<tr v-for="field in detail.version.fields" :key="field.key">
<th scope="row">
{{ fieldLabels[field.key] ?? field.key }} <span v-if="field.changed">(변경)</span>
</th>
<td>
<pre>{{ field.beforeJson ?? '관측하지 않음' }}</pre>
</td>
<td>
<pre>{{ field.afterJson }}</pre>
</td>
</tr>
</tbody>
</table>
</div>
<button
v-if="detail.version.previousId"
class="legacy-button"
@click="select(detail.version.previousId)"
>
이전 정책 버전
</button>
<details>
<summary>요청 연결</summary>
<p>요청 {{ detail.version.requestId ?? '해당 없음' }}</p>
<p>입력 순번 {{ detail.version.inputSequence ?? '해당 없음' }}</p>
</details>
</template>
</section>
</section>
</template>
<style scoped>
.table-scroll {
overflow-x: auto;
}
table {
width: 100%;
min-width: 640px;
border-collapse: collapse;
}
th,
td {
border: 1px solid gray;
padding: 6px;
text-align: left;
}
pre {
white-space: pre-wrap;
overflow-wrap: anywhere;
max-width: 400px;
font: inherit;
margin: 0;
}
h3 {
font-size: var(--sammo-font-size-normal);
}
[role='alert'] {
color: #ffb9b9;
}
details {
overflow-wrap: anywhere;
}
</style>
+61 -8
View File
@@ -5,6 +5,7 @@ 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 AuditPolicyHistory from '../components/playAudit/AuditPolicyHistory.vue';
import AuditCityDetail from '../components/playAudit/AuditCityDetail.vue';
import { usePageExit } from '../composables/usePageExit';
import { trpc } from '../utils/trpc';
@@ -29,6 +30,28 @@ const profileName = ref('');
const loading = ref(false);
const error = ref('');
const tab = ref('nations');
const policyArea = ref<'NPC_VALUES' | 'NPC_NATION_PRIORITY' | 'NPC_GENERAL_PRIORITY' | 'DEFENCE'>('NPC_VALUES');
const appliedPolicy = computed(() => {
const to = {
year: numeric(route.query.year, coverage.value?.year ?? 0),
month: numeric(route.query.month, coverage.value?.month ?? 1),
};
const start = Math.max(
(coverage.value?.startYear ?? to.year) * 12 + (coverage.value?.startMonth ?? 1) - 1,
to.year * 12 + to.month - 6
);
return {
nationId: numeric(route.query.nation, 0),
area: (['NPC_NATION_PRIORITY', 'NPC_GENERAL_PRIORITY', 'DEFENCE'].includes(String(route.query.policyArea))
? route.query.policyArea
: 'NPC_VALUES') as typeof policyArea.value,
from: {
year: numeric(route.query.fromYear, Math.floor(start / 12)),
month: numeric(route.query.fromMonth, (start % 12) + 1),
},
to,
};
});
const nationId = ref('');
const cityId = ref('');
const population = ref('');
@@ -91,9 +114,10 @@ const result = computed(() =>
: series.value
);
const readQuery = () => {
tab.value = ['nations', 'generals', 'cities'].includes(String(route.query.tab))
tab.value = ['nations', 'generals', 'cities', 'policies'].includes(String(route.query.tab))
? String(route.query.tab)
: 'nations';
policyArea.value = appliedPolicy.value.area;
nationId.value = route.query.nation ? String(numeric(route.query.nation, 0)) : '';
cityId.value = route.query.city ? String(numeric(route.query.city, 0)) : '';
population.value = ['human', 'npc', 'troopNpc'].includes(String(route.query.population))
@@ -149,6 +173,8 @@ const load = async (append = false) => {
...response,
items: append ? [...(cities.value?.items ?? []), ...response.items] : response.items,
};
} else if (tab.value === 'policies') {
// 정책 목록/상세는 해당 component가 필요한 요청만 실행한다.
} else if (nationId.value !== '' && moment.value === 'final') {
const response = await trpc.playAudit.nationSnapshot.query({
nationId: Number(nationId.value),
@@ -201,6 +227,7 @@ const apply = async () => {
fromYear: String(fromYear.value),
fromMonth: String(fromMonth.value),
resolution: resolution.value,
policyArea: tab.value === 'policies' ? policyArea.value : undefined,
};
if (JSON.stringify(route.query) === JSON.stringify(query)) await refresh();
else {
@@ -236,7 +263,10 @@ const moreNations = async () => {
}
};
watch(
() => JSON.stringify(Object.entries(route.query).filter(([key]) => key !== 'general' && key !== 'cityRecord')),
() =>
JSON.stringify(
Object.entries(route.query).filter(([key]) => key !== 'general' && key !== 'cityRecord' && key !== 'policy')
),
() => {
if (authorized.value) {
readQuery();
@@ -286,11 +316,14 @@ onMounted(async () => {
<option value="nations">국가 시계열</option>
<option value="generals">전체 장수</option>
<option value="cities">도시 상태</option>
<option value="policies">정책 변경 이력</option>
</select></label
>
<label
>국가<select class="legacy-sort-select" v-model="nationId">
<option value="">{{ tab === 'nations' ? '국가 선택' : '모든 국가' }}</option>
<option value="">
{{ tab === 'nations' || tab === 'policies' ? '국가 선택' : '모든 국가' }}
</option>
<option value="0">무소속</option>
<option
v-for="nation in nations?.items.filter((item) => item.id !== 0)"
@@ -328,7 +361,7 @@ onMounted(async () => {
</select></label
>
<label
>{{ tab === 'nations' ? '종료 연도' : '표본 연도'
>{{ tab === 'nations' || tab === 'policies' ? '종료 연도' : '표본 연도'
}}<input
v-model.number="year"
type="number"
@@ -344,7 +377,7 @@ onMounted(async () => {
:max="year === coverage.year ? coverage.month : 12"
required
/></label>
<template v-if="tab === 'nations' && moment !== 'final'">
<template v-if="(tab === 'nations' && moment !== 'final') || tab === 'policies'">
<label
>시작 연도<input
v-model.number="fromYear"
@@ -361,13 +394,21 @@ onMounted(async () => {
:max="fromYear === coverage.year ? coverage.month : 12"
required
/></label>
<label
<label v-if="tab === 'nations'"
>간격<select class="legacy-sort-select" v-model="resolution">
<option value="halfYear">반기 (1~6월 / 7~12월)</option>
<option value="month">매월</option>
</select></label
>
</template>
<label v-if="tab === 'policies'"
>정책 영역<select class="legacy-sort-select" v-model="policyArea">
<option value="NPC_VALUES">NPC 국가 정책</option>
<option value="NPC_NATION_PRIORITY">국가 행동 우선순위</option>
<option value="NPC_GENERAL_PRIORITY">장수 행동 우선순위</option>
<option value="DEFENCE">국방 설정</option>
</select></label
>
<template v-if="tab === 'generals'">
<label>도시 번호<input v-model="cityId" type="number" min="0" placeholder="모든 도시" /></label>
<label
@@ -385,12 +426,24 @@ onMounted(async () => {
</PanelCard>
<PanelCard
v-if="authorized && coverage"
:title="tab === 'nations' ? '국가 시계열' : tab === 'generals' ? '전체 장수' : '도시 상태'"
:title="
tab === 'nations'
? '국가 시계열'
: tab === 'generals'
? '전체 장수'
: tab === 'policies'
? '정책 변경 이력'
: '도시 상태'
"
>
<p v-if="result">조회 시각 {{ result.asOf }} · tick {{ result.tick ?? '없음' }}</p>
<p v-if="tab === 'nations' && !nationId">
<p v-if="(tab === 'nations' || tab === 'policies') && !nationId">
국가를 선택하고 조회해 주세요. 멸망한 국가는 해당 월말 기준의 국가 목록에서 선택할 수 있습니다.
</p>
<AuditPolicyHistory
v-if="tab === 'policies' && route.query.tab === 'policies' && route.query.nation"
v-bind="appliedPolicy"
/>
<AuditNationSeries v-if="series && tab === 'nations'" :data="series" />
<AuditNationSnapshot v-if="nationSnapshot && tab === 'nations'" :data="nationSnapshot" />
<template v-if="generals && tab === 'generals'">