343 lines
15 KiB
Vue
343 lines
15 KiB
Vue
<script setup lang="ts">
|
|
import AuditEffectivePolicy from './AuditEffectivePolicy.vue';
|
|
import { computed, ref, watch } from 'vue';
|
|
import { useRoute, useRouter } from 'vue-router';
|
|
import { trpc } from '../../utils/trpc';
|
|
import AuditPolicyVersion from './AuditPolicyVersion.vue';
|
|
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 selectedMonth = ref<{ year: number; month: number }>();
|
|
const ordinal = (value: { year: number; month: number }) => value.year * 12 + value.month - 1;
|
|
const canPrevious = computed(
|
|
() => history.value && ordinal(history.value.month) > history.value.startYear * 12 + history.value.startMonth - 1
|
|
);
|
|
const canNext = computed(() => history.value && ordinal(history.value.month) < ordinal(history.value.currentMonth));
|
|
const detailLoading = ref(false);
|
|
let generation = 0;
|
|
let detailGeneration = 0;
|
|
const selected = computed(() => (typeof route.query.decision === 'string' ? route.query.decision : null));
|
|
const policyLabels = {
|
|
NPC_VALUES: 'NPC 설정 값',
|
|
NPC_NATION_PRIORITY: '수뇌 우선순위',
|
|
NPC_GENERAL_PRIORITY: '개인 우선순위',
|
|
DEFENCE: '국방 설정',
|
|
};
|
|
const selectedPolicy = computed(() => {
|
|
const id = route.query.policy;
|
|
return typeof id === 'string' && Object.values(detail.value?.decision.summary.policyRefs ?? {}).includes(id)
|
|
? id
|
|
: null;
|
|
});
|
|
const selectPolicy = (id: string | null) => router.push({ query: { ...route.query, policy: id ?? undefined } });
|
|
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 ?? selectedMonth.value) : selectedMonth.value,
|
|
limit: 50,
|
|
cursor: more ? (history.value?.nextCursor ?? undefined) : undefined,
|
|
});
|
|
if (request === generation)
|
|
history.value = {
|
|
...response,
|
|
selection: more ? (history.value?.selection ?? response.selection) : response.selection,
|
|
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 changeMonth = (offset: number) => {
|
|
if (!history.value || loading.value) return;
|
|
const value = ordinal(history.value.month) + offset;
|
|
selectedMonth.value = { year: Math.floor(value / 12), month: (value % 12) + 1 };
|
|
void select(null);
|
|
void load();
|
|
};
|
|
const latest = () => {
|
|
selectedMonth.value = undefined;
|
|
void select(null);
|
|
void load();
|
|
};
|
|
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, status?: 'PREPARING' | 'BLOCKED' | 'RESOLVED') =>
|
|
status === 'PREPARING'
|
|
? '준비 중'
|
|
: status === 'BLOCKED'
|
|
? '실행 차단'
|
|
: 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 checkLabels = {
|
|
ARGS: '인자',
|
|
CONSTRAINT: '조건',
|
|
COOLDOWN: '재사용 대기',
|
|
CONTEXT: '실행 문맥',
|
|
BLOCK: '실행 제한',
|
|
};
|
|
const checkResultLabels = { allow: '통과', deny: '차단', unknown: '미확인' };
|
|
const stepText = (step: Step): string => {
|
|
switch (step.kind) {
|
|
case 'EXECUTION_ATTEMPT':
|
|
return `실행 시도 ${step.attempt + 1} · 요청 ${step.requestedAction} · 처리 ${step.resolvedAction} → 실행 ${step.executedAction ?? '미실행'} · ${step.preparation ? '준비 중' : outcome(step.completed)}${step.usedFallback ? ' · 대체 실행' : ''}${step.alternativeAction ? ` · 다음 대안 ${step.alternativeAction}` : ''}${step.preparation ? ` · 준비 ${step.preparation.term}/${step.preparation.total}` : ''}`;
|
|
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++;
|
|
selectedMonth.value = props.month;
|
|
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 v-if="history?.selection === 'LATEST' && history.items.length">
|
|
가장 최근 결정이 수집된 월입니다. 현재 게임은 {{ history.currentMonth.year }}년
|
|
{{ history.currentMonth.month }}월입니다.
|
|
</p>
|
|
<p>각 장수의 턴 실행 후 저장됩니다. 월이 바뀌어도 해당 장수의 다음 턴 전까지는 이전 월 기록이 최신입니다.</p>
|
|
<nav aria-label="결정 조회 월">
|
|
<button class="legacy-button" :disabled="loading || !canPrevious" @click="changeMonth(-1)">이전 월</button>
|
|
<button class="legacy-button" :disabled="loading || !canNext" @click="changeMonth(1)">다음 월</button>
|
|
<button class="legacy-button" :disabled="loading" @click="latest">최근 결정 조회</button>
|
|
</nav>
|
|
<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">
|
|
{{
|
|
history.selection === 'LATEST'
|
|
? '이 장수의 현재 기수에 수집된 결정 기록이 없습니다.'
|
|
: '선택한 월에 수집된 결정 기록이 없습니다. 최근 결정 조회로 마지막 기록을 확인할 수 있습니다.'
|
|
}}
|
|
</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.executionStatus)
|
|
}}{{ 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 v-if="!detail.decision.summary.executionCoverage">
|
|
이 기록에는 실행 단계의 시도 이력이 수집되지 않았습니다.
|
|
</p>
|
|
<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, detail.decision.summary.executionStatus) }}
|
|
</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>선택 당시 저장된 국가 설정입니다. NPC별 합성 유효 값과 다를 수 있습니다.</p>
|
|
<p v-for="(id, area) in detail.decision.summary.policyRefs" :key="area">
|
|
<button class="legacy-button" @click="selectPolicy(id ?? null)">
|
|
{{ policyLabels[area] }} 당시 버전 조회
|
|
</button>
|
|
</p>
|
|
</details>
|
|
<AuditPolicyVersion
|
|
v-if="selectedPolicy"
|
|
:id="selectedPolicy"
|
|
:allow-previous="false"
|
|
@select="selectPolicy"
|
|
/>
|
|
<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) }}
|
|
<template v-if="step.kind === 'DECISION_START'">
|
|
<AuditEffectivePolicy v-if="step.effectivePolicy" :policy="step.effectivePolicy" />
|
|
<p v-else>당시 합성 정책 미수집</p>
|
|
</template>
|
|
<ul v-if="step.kind === 'EXECUTION_ATTEMPT'">
|
|
<li v-for="(check, index) in step.checks" :key="index">
|
|
{{ checkLabels[check.stage] }} · {{ check.action }} ·
|
|
{{ checkResultLabels[check.result] }}{{ check.reason ? ` · ${check.reason}` : '' }}
|
|
</li>
|
|
</ul>
|
|
</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;
|
|
}
|
|
.audit-decisions > section {
|
|
min-width: 0;
|
|
}
|
|
.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>
|