NPC 정책 적용을 검증하고 자국 변경 이력 조회를 제공한다

This commit is contained in:
2026-10-22 23:50:35 +09:00
parent 87f810a878
commit 039c5d2a59
9 changed files with 977 additions and 3 deletions
@@ -0,0 +1,191 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import { trpc } from '../utils/trpc';
import { fieldLabels } from './playAudit/policyLabels';
const props = defineProps<{ revision: number }>();
type History = Awaited<ReturnType<typeof trpc.npc.policyHistory.query>>;
type Version = Awaited<ReturnType<typeof trpc.npc.policyVersion.query>>;
const open = ref(false);
const area = ref<'NPC_VALUES' | 'NPC_NATION_PRIORITY' | 'NPC_GENERAL_PRIORITY'>('NPC_VALUES');
const history = ref<History | null>(null);
const version = ref<Version | null>(null);
const loading = ref(false);
const error = ref('');
const labels = { BASELINE: '최초 관측', CHANGE: '변경', OBSERVED_GAP: '관측 누락 이후 기준' };
let generation = 0;
const load = async (append = false) => {
const request = ++generation;
loading.value = true;
error.value = '';
if (!append) {
history.value = null;
version.value = null;
}
try {
const response = await trpc.npc.policyHistory.query({
area: area.value,
cursor: append ? (history.value?.nextCursor ?? undefined) : undefined,
});
if (request === generation)
history.value = {
...response,
items: append ? [...(history.value?.items ?? []), ...response.items] : response.items,
};
} catch (cause) {
if (request === generation)
error.value = cause instanceof Error ? cause.message : '이력을 조회하지 못했습니다.';
} finally {
if (request === generation) loading.value = false;
}
};
const select = async (id: string) => {
const request = ++generation;
loading.value = true;
error.value = '';
version.value = null;
try {
const response = await trpc.npc.policyVersion.query({ id });
if (request === generation) version.value = response;
} catch (cause) {
if (request === generation)
error.value = cause instanceof Error ? cause.message : '변경 내용을 조회하지 못했습니다.';
} finally {
if (request === generation) loading.value = false;
}
};
watch([open, area, () => props.revision], () => {
if (open.value) void load();
else {
++generation;
loading.value = false;
history.value = null;
version.value = null;
}
});
</script>
<template>
<section class="npc-policy-history" aria-label="NPC 정책 변경 이력">
<button class="legacy-button" :aria-expanded="open" @click="open = !open">
정책 변경 이력 {{ open ? '닫기' : '조회' }}
</button>
<div v-if="open">
<p>자국의 현재 기수 기록입니다. 최초 관측 이전의 변경은 복원하지 않습니다.</p>
<p>우선순위는 실행을 시도하는 순서입니다. 징병은 전쟁 상태·병력·인구·자원 조건을 만족해야 합니다.</p>
<p>0이면 자동 계산하는 항목은 저장값이 같아도 기술·내정 비용에 따라 기준이 달라질 수 있습니다.</p>
<label
>이력 종류
<select v-model="area">
<option value="NPC_VALUES">국가 정책</option>
<option value="NPC_NATION_PRIORITY">국가턴 우선순위</option>
<option value="NPC_GENERAL_PRIORITY">일반턴 우선순위</option>
</select>
</label>
<button class="legacy-button" :disabled="loading" @click="load()">이력 새로고침</button>
<p v-if="loading" role="status">조회 중…</p>
<p v-if="error" role="alert">{{ error }}</p>
<template v-if="history">
<p v-if="history.historyGap" role="status">
{{ history.historyGap.firstYear }}년 {{ history.historyGap.firstMonth }}월 이후 일부 기록에 누락이
있습니다. 이력이 없다는 사실만으로 변경이 없었다고 판단할 수 없습니다.
</p>
<p v-if="history.coverage === 'IDENTITY_MISSING'">기수 식별자가 없어 이력을 조회할 수 없습니다.</p>
<p v-else-if="!history.items.length">기록된 정책 이력이 없습니다.</p>
<ol v-else>
<li v-for="item in history.items" :key="item.id">
<button class="legacy-button" :disabled="loading" @click="select(item.id)">
버전 {{ item.revision }}
</button>
{{ item.year }}년 {{ item.month }}월 · {{ labels[item.source] }} ·
{{ item.actor ? `${item.actor.name} (#${item.actor.generalId})` : '변경 주체 미상' }}
</li>
</ol>
<button
v-if="history.nextCursor !== null"
class="legacy-button"
:disabled="loading"
@click="load(true)"
>
이전 기록 더 보기
</button>
</template>
<section v-if="version" aria-label="정책 변경 내용">
<h3>버전 {{ version.revision }} · {{ version.actor?.name ?? '변경 주체 미상' }}</h3>
<p>{{ version.year }}년 {{ version.month }}월 · 기록 시각 {{ version.createdAt }}</p>
<p v-if="version.source !== 'CHANGE'">관측 기준입니다. 이전 값과 변경 주체를 추정하지 않습니다.</p>
<p>null은 개별 설정 없이 기본값을 상속함을 뜻합니다.</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 version.fields" :key="field.key" :class="{ changed: field.changed }">
<th scope="row">
{{ fieldLabels[field.key] ?? field.key }}{{ field.changed ? ' (변경)' : '' }}
</th>
<td>
<pre>{{ field.beforeJson ?? '관측하지 않음' }}</pre>
</td>
<td>
<pre>{{ field.afterJson }}</pre>
</td>
</tr>
</tbody>
</table>
</div>
</section>
</div>
</section>
</template>
<style scoped>
.npc-policy-history {
margin-top: 16px;
padding: 8px;
border: 1px solid gray;
}
select,
button {
margin: 4px;
vertical-align: middle;
}
li {
margin-block: 6px;
}
.table-scroll {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
}
th,
td {
border: 1px solid gray;
padding: 6px;
text-align: left;
vertical-align: middle;
}
pre {
white-space: pre-wrap;
overflow-wrap: anywhere;
min-width: 100px;
margin: 0;
font: inherit;
}
.changed {
background: #343425;
}
[role='alert'] {
color: #ffb9b9;
}
h3 {
font-size: var(--sammo-font-size-normal);
}
</style>
@@ -3,12 +3,14 @@ import { usePageExit } from '../composables/usePageExit';
import { computed, onMounted, ref, watch } from 'vue';
import NpcPolicyHistory from '../components/NpcPolicyHistory.vue';
import SortableStringList from '../components/ui/SortableStringList';
import { useGameFeedback } from '../composables/useGameFeedback';
import { npcPriorityHelp } from '../utils/npcPriorityHelp';
import { trpc } from '../utils/trpc';
const { pageExitLabel, exitPage } = usePageExit();
const historyRevision = ref(0);
type NpcPolicyResponse = Awaited<ReturnType<typeof trpc.npc.getPolicy.query>>;
type NationPolicy = NpcPolicyResponse['currentNationPolicy'];
@@ -308,6 +310,7 @@ const submitPolicy = async () => {
try {
await trpc.npc.setNationPolicy.mutate(policyDraft.value);
lastSavedPolicy.value = clonePolicy(policyDraft.value);
historyRevision.value += 1;
showSuccessToast('NPC 정책이 반영되었습니다.');
} catch (caught) {
showErrorToast(`설정하지 못했습니다: ${resolveErrorMessage(caught)}`);
@@ -357,6 +360,7 @@ const submitPriority = async (section: PrioritySectionKey) => {
await trpc.npc.setGeneralPriority.mutate(state.active);
lastSavedGeneralPriority.value = [...state.active];
}
historyRevision.value += 1;
showSuccessToast('NPC 정책이 반영되었습니다.');
} catch (caught) {
showErrorToast(`설정하지 못했습니다: ${resolveErrorMessage(caught)}`);
@@ -547,6 +551,7 @@ const submitPriority = async (section: PrioritySectionKey) => {
</section>
</div>
</section>
<NpcPolicyHistory v-if="data" :key="data.nationId" :revision="historyRevision" />
<div class="sortable-compat-controls" aria-hidden="true">
<button type="button" tabindex="-1" />
<input v-for="index in 20" :key="index" type="hidden" />