NPC 결정 기록 조회와 월별 인덱스를 연결하고 감사 화면 검증

This commit is contained in:
2026-09-16 08:14:48 +00:00
parent 63907d95cc
commit 22cf566c6b
15 changed files with 772 additions and 17 deletions
@@ -0,0 +1,244 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { trpc } from '../../utils/trpc';
const props = defineProps<{ generalId: number; month?: { year: number; month: number } }>();
const route = useRoute();
const router = useRouter();
type History = Awaited<ReturnType<typeof trpc.playAudit.decisionHistory.query>>;
type Detail = Awaited<ReturnType<typeof trpc.playAudit.decisionDetail.query>>;
type Step = Detail['chunks'][number]['steps'][number];
const history = 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.decision === 'string' ? route.query.decision : null));
const message = (cause: unknown) => (cause instanceof Error ? cause.message : 'NPC 결정 기록을 조회하지 못했습니다.');
const load = async (more = false) => {
if (loading.value) return;
const request = generation;
loading.value = true;
error.value = '';
try {
const response = await trpc.playAudit.decisionHistory.query({
generalId: props.generalId,
month: more ? (history.value?.month ?? props.month) : props.month,
limit: 50,
cursor: more ? (history.value?.nextCursor ?? undefined) : undefined,
});
if (request === generation)
history.value = {
...response,
items: more ? [...(history.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 (more = false) => {
if (!selected.value || detailLoading.value) return;
const request = detailGeneration;
detailLoading.value = true;
detailError.value = '';
try {
const response = await trpc.playAudit.decisionDetail.query({
id: selected.value,
generalId: props.generalId,
cursor: more ? (detail.value?.nextCursor ?? undefined) : undefined,
limit: 1,
});
if (request === detailGeneration)
detail.value = {
...response,
chunks: more ? [...(detail.value?.chunks ?? []), ...response.chunks] : response.chunks,
};
} 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, decision: id ?? undefined } });
const outcome = (done: boolean | null) => (done === null ? '결과 미관측' : done ? '실행 완료' : '실행 실패');
const rngValue = (value: Extract<Step, { kind: 'RNG' }>['result']): string => {
if (Array.isArray(value)) return value.map(rngValue).join(', ');
if (value === null) return '없음';
if (typeof value === 'object') return 'entityId' in value ? `대상 #${value.entityId}` : '상세 값 미수집';
return String(value);
};
const stepText = (step: Step): string => {
switch (step.kind) {
case 'DECISION_START':
return `판단 시작 · 예약 ${step.reservedAction}`;
case 'DECISION_END':
return `최종 선택 · ${step.action ?? '선택 없음'} · ${step.reason ?? '사유 미관측'}`;
case 'DECISION_ERROR':
return '판단 중 오류';
case 'PROCEDURE_START':
return `${step.procedure} · 평가 시작`;
case 'PROCEDURE_END':
return `${step.procedure} · ${step.action ?? '선택 없음'} · ${step.reason ?? '내부 사유 미수집'}`;
case 'PROCEDURE_SKIP':
return `${step.procedure} · ${{ POLICY: '정책으로 제외', AUTOMATION: '자동화 권한으로 제외', NO_HANDLER: '처리 절차 없음' }[step.reason]}`;
case 'CANDIDATE':
return `${step.action} · ${{ INVALID_ARGS: '인자 오류', allow: '조건 통과', deny: '조건 차단', unknown: '조건 미확인' }[step.result]}${step.constraint ? ` · ${step.constraint}` : ''}`;
case 'RNG':
return `${step.method}(${step.parameters?.join(', ') ?? ''}) → ${rngValue(step.result)}`;
}
};
watch(
[() => props.generalId, () => props.month?.year, () => props.month?.month],
() => {
generation++;
history.value = null;
error.value = '';
loading.value = false;
void load();
},
{ immediate: true }
);
watch(
[selected, () => props.generalId],
() => {
detailGeneration++;
detail.value = null;
detailError.value = '';
detailLoading.value = false;
if (selected.value) void loadDetail();
},
{ immediate: true }
);
</script>
<template>
<section class="audit-decisions" aria-label="NPC 결정 기록">
<p>
{{ history ? `${history.month.year}${history.month.month}` : '선택 월' }} · NPC·유저 자동턴의 개인/수뇌
판단
</p>
<p>
절차와 선택 결과를 수집한 기록입니다. 후보 내부 조건 전체는 아직 포함되지 않으며, 기록이 없다고 판단 시도가
없었다는 뜻은 아닙니다.
</p>
<p v-if="loading" role="status">결정 목록 조회 </p>
<p v-if="error" role="alert">
{{ error }} <button class="legacy-button" @click="load()">결정 목록 다시 조회</button>
</p>
<p v-if="history && !history.items.length"> 월에 수집된 결정 기록이 없습니다.</p>
<div v-if="history?.items.length" class="table-scroll">
<table>
<thead>
<tr>
<th>판단</th>
<th>주체</th>
<th>선택 실행</th>
<th>결과</th>
</tr>
</thead>
<tbody>
<tr v-for="item in history.items" :key="item.id">
<td>
<button class="legacy-button" @click="select(item.id)">
{{ item.phase === 'nation' ? '수뇌 판단' : '개인 판단' }} · tick {{ item.tick }}
</button>
</td>
<td>{{ item.npcState < 2 ? '유저 자동턴' : item.npcState === 5 ? '부대장 NPC' : 'NPC' }}</td>
<td>{{ item.summary.selectedAction ?? '선택 없음' }} {{ item.summary.executedAction }}</td>
<td>
{{ outcome(item.summary.completed) }}{{ item.summary.usedFallback ? ' · 대체 실행' : '' }}
</td>
</tr>
</tbody>
</table>
</div>
<button v-if="history?.nextCursor" class="legacy-button" :disabled="loading" @click="load(true)">
결정 목록 불러오기
</button>
<section v-if="selected" aria-label="선택 결정 상세">
<button class="legacy-button" @click="select(null)">결정 상세 닫기</button>
<p v-if="detailLoading" role="status">결정 상세 조회 </p>
<p v-if="detailError" role="alert">
{{ detailError }}
<button class="legacy-button" @click="loadDetail(Boolean(detail))">결정 상세 다시 조회</button>
</p>
<template v-if="detail">
<p>
{{ detail.decision.year }} {{ detail.decision.month }} · 국가 #{{ detail.decision.nationId }} ·
도시 #{{ detail.decision.cityId }} · tick {{ detail.decision.tick }}
</p>
<p>
예약 {{ detail.decision.summary.requestedAction }} · 선택
{{ detail.decision.summary.selectedAction ?? '없음' }} · 실행
{{ detail.decision.summary.executedAction }}
</p>
<p>
선택 사유: {{ detail.decision.summary.selectedReason ?? '미관측' }} ·
{{ outcome(detail.decision.summary.completed) }}
</p>
<p v-if="detail.decision.summary.blockedReason">
차단 사유: {{ detail.decision.summary.blockedReason }}
</p>
<p>
코드 버전: {{ detail.decision.summary.codeVersion ?? '미관측' }} · 전체 관측
{{ detail.decision.stepCount }}
</p>
<details>
<summary>당시 정책 참조</summary>
<p v-if="!Object.keys(detail.decision.summary.policyRefs).length">확보된 정책 참조가 없습니다.</p>
<p v-for="(id, area) in detail.decision.summary.policyRefs" :key="area">{{ area }}: {{ id }}</p>
</details>
<ol aria-label="판단 절차">
<template v-for="chunk in detail.chunks" :key="chunk.ordinal"
><li v-for="step in chunk.steps" :key="step.sequence" :value="step.sequence + 1">
{{ stepText(step) }}
</li></template
>
</ol>
<button
v-if="detail.nextCursor !== null"
class="legacy-button"
:disabled="detailLoading"
@click="loadDetail(true)"
>
판단 절차 불러오기
</button>
</template>
</section>
</section>
</template>
<style scoped>
.audit-decisions {
display: grid;
gap: 8px;
margin-top: 12px;
min-width: 0;
overflow-wrap: anywhere;
}
.table-scroll {
overflow-x: auto;
}
table {
border-collapse: collapse;
min-width: 640px;
width: 100%;
}
th,
td {
padding: 6px;
text-align: left;
border: 1px solid gray;
}
ol {
padding-left: 28px;
}
li {
padding: 4px 0;
}
</style>
@@ -1,5 +1,7 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import { computed, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import AuditGeneralDecisions from './AuditGeneralDecisions.vue';
import PanelCard from '../ui/PanelCard.vue';
import AuditGeneralLogs from './AuditGeneralLogs.vue';
import { trpc } from '../../utils/trpc';
@@ -17,6 +19,16 @@ const turnsLoading = ref(false);
const error = ref('');
const turnsError = ref('');
const showLogs = ref(false);
const route = useRoute();
const router = useRouter();
const showDecisions = ref(false);
const decisionsOpen = computed(() => showDecisions.value || typeof route.query.decision === 'string');
const toggleDecisions = async () => {
if (decisionsOpen.value) {
showDecisions.value = false;
await router.push({ query: { ...route.query, decision: undefined } });
} else showDecisions.value = true;
};
let generation = 0;
const format = (value: number) => value.toLocaleString('ko-KR', { maximumFractionDigits: 2 });
const load = async () => {
@@ -126,6 +138,14 @@ watch(
</template>
</template>
</template>
<button class="legacy-button" @click="toggleDecisions">
{{ decisionsOpen ? 'NPC 결정 기록 닫기' : 'NPC 결정 기록 조회' }}
</button>
<AuditGeneralDecisions
v-if="decisionsOpen"
:general-id="generalId"
:month="at ? { year: at.year, month: at.month } : undefined"
/>
<button class="legacy-button" @click="showLogs = !showLogs">
{{ showLogs ? '장수 기록 닫기' : '장수 기록 조회' }}
</button>
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { trpc } from '../../utils/trpc';
import type { trpc } from '../../utils/trpc';
type Series = Awaited<ReturnType<typeof trpc.playAudit.nationSeries.query>>;
type Point = Series['items'][number];
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed } from 'vue';
import { trpc } from '../../utils/trpc';
import type { trpc } from '../../utils/trpc';
type Snapshot = Awaited<ReturnType<typeof trpc.playAudit.nationSnapshot.query>>;
const props = defineProps<{ data: Snapshot }>();
const label = computed(() => (props.data.sample?.kind === 'INITIAL' ? '수집 시작 기준' : '최종 표본'));
@@ -97,10 +97,10 @@ const selectedCity = computed(() =>
: null
);
const selectGeneral = (id: number) =>
router.push({ query: { ...route.query, general: String(id), cityRecord: undefined } });
const closeGeneral = () => router.push({ query: { ...route.query, general: undefined } });
router.push({ query: { ...route.query, general: String(id), cityRecord: undefined, decision: undefined } });
const closeGeneral = () => router.push({ query: { ...route.query, general: undefined, decision: undefined } });
const selectCity = (id: number) =>
router.push({ query: { ...route.query, cityRecord: String(id), general: undefined } });
router.push({ query: { ...route.query, cityRecord: String(id), general: undefined, decision: undefined } });
const closeCity = () => router.push({ query: { ...route.query, cityRecord: undefined } });
const at = computed(() =>
moment.value === 'current'
@@ -296,7 +296,12 @@ watch(
() =>
JSON.stringify(
Object.entries(route.query).filter(
([key]) => key !== 'general' && key !== 'cityRecord' && key !== 'policy' && key !== 'event'
([key]) =>
key !== 'general' &&
key !== 'cityRecord' &&
key !== 'policy' &&
key !== 'event' &&
key !== 'decision'
)
),
() => {