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
+139
View File
@@ -43,6 +43,34 @@ const general = {
items: { horse: null, weapon: null, book: null, item: null },
},
};
const decision = {
id: 'd'.repeat(64),
executionId: 'e'.repeat(64),
phase: 'general',
generalId: 1,
nationId: 2,
cityId: 3,
npcState: 2,
year: 190,
month: 6,
tick: '100',
stepCount: 129,
createdAt: '2026-09-16T00:00:00.000Z',
summary: {
schemaVersion: 1,
coverage: 'PROCEDURES',
clockRevision: 1,
codeVersion: null,
policyRefs: {},
requestedAction: '휴식',
selectedAction: 'che_징병',
selectedReason: '징병 선택',
executedAction: '휴식',
completed: false,
usedFallback: true,
blockedReason: '자원 부족',
},
};
const install = async (page: Page, denied = false, baseline: boolean | 'document' | 'created' | 'removed' = false) => {
const requests: { operation: string; input: Record<string, unknown> }[] = [];
await page.addInitScript((profile) => {
@@ -73,6 +101,47 @@ const install = async (page: Page, denied = false, baseline: boolean | 'document
},
}
: result({ profileName: gameProfile, read: true, accounts: false });
case 'playAudit.decisionHistory':
return result({
...world,
month: input.month ?? { year: 190, month: 7 },
coverage: 'PROCEDURES_ONLY',
items: [
{
...decision,
id: input.cursor ? 'c'.repeat(64) : decision.id,
phase: input.cursor ? 'nation' : 'general',
},
],
nextCursor: input.cursor ? null : { tick: '100', id: decision.id },
});
case 'playAudit.decisionDetail':
return result({
...world,
decision,
chunks: [
{
ordinal: input.cursor === undefined ? 0 : 1,
steps: [
{
phase: 'general',
generalId: 1,
nationId: 2,
cityId: 3,
npcState: 2,
year: 190,
month: 6,
tick: 100,
sequence: input.cursor === undefined ? 0 : 128,
...(input.cursor === undefined
? { kind: 'PROCEDURE_START', procedure: '<b>징병판정</b>' }
: { kind: 'DECISION_END', action: 'che_징병', reason: '징병 선택' }),
},
],
},
],
nextCursor: input.cursor === undefined ? 0 : null,
});
case 'playAudit.generalLogs':
return result({
...world,
@@ -1015,3 +1084,73 @@ test('general search is explicit and persists across pagination and reload', asy
await expect(page.getByRole('rowheader', { name: /감사장수/ })).toBeVisible();
await capture(page, 'mobile-general-search');
});
test('NPC decisions are explicit, paginated, independently addressable and escaped', 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&general=1'));
await expect(page.getByRole('heading', { name: '선택 장수 상세' })).toBeVisible();
expect(requests.some((r) => r.operation.startsWith('playAudit.decision'))).toBe(false);
await page.getByRole('button', { name: 'NPC 결정 기록 조회', exact: true }).click();
await expect(page.getByRole('button', { name: '개인 판단 · tick 100', exact: true })).toBeVisible();
expect(requests.filter((r) => r.operation === 'playAudit.decisionHistory').at(-1)?.input).toMatchObject({
generalId: 1,
month: { year: 190, month: 6 },
});
expect(requests.some((r) => r.operation === 'playAudit.decisionDetail')).toBe(false);
const counts = {
list: requests.filter((r) => r.operation === 'playAudit.generals').length,
history: requests.filter((r) => r.operation === 'playAudit.decisionHistory').length,
};
await page.getByRole('button', { name: '개인 판단 · tick 100', exact: true }).click();
await expect(page.getByRole('list', { name: '판단 절차' })).toContainText('<b>징병판정</b>');
await expect(page.getByRole('list', { name: '판단 절차' }).locator('b')).toHaveCount(0);
await page.getByRole('button', { name: '판단 절차 더 불러오기', exact: true }).click();
await expect(page.getByRole('list', { name: '판단 절차' })).toContainText('최종 선택');
expect(requests.filter((r) => r.operation === 'playAudit.decisionDetail').at(-1)?.input).toMatchObject({
id: decision.id,
generalId: 1,
cursor: 0,
limit: 1,
});
expect(requests.filter((r) => r.operation === 'playAudit.generals')).toHaveLength(counts.list);
expect(requests.filter((r) => r.operation === 'playAudit.decisionHistory')).toHaveLength(counts.history);
await capture(page, 'mobile-npc-decision');
await page.reload();
await expect(page.getByRole('list', { name: '판단 절차' })).toContainText('징병판정');
await page.getByRole('button', { name: '결정 목록 더 불러오기', exact: true }).click();
await expect(page.getByRole('button', { name: '수뇌 판단 · tick 100', exact: true })).toBeVisible();
});
test('NPC decision detail retry preserves history and other general information', async ({ page }) => {
const requests = await install(page);
let fail = true;
await page.route(gameTrpcRoute, async (route) => {
if (fail && route.request().url().includes('playAudit.decisionDetail')) {
fail = false;
await route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify([
{
error: {
message: '결정 상세 재시도',
code: -32603,
data: { code: 'INTERNAL_SERVER_ERROR', httpStatus: 500 },
},
},
]),
});
} else await route.fallback();
});
await page.goto(gamePath('/play-audit?tab=generals&general=1'));
await page.getByRole('button', { name: 'NPC 결정 기록 조회', exact: true }).click();
await page.getByRole('button', { name: '개인 판단 · tick 100', exact: true }).click();
await expect(page.getByRole('alert')).toContainText('결정 상세 재시도');
await expect(page.getByRole('button', { name: '개인 판단 · tick 100', exact: true })).toBeVisible();
const count = requests.filter((r) => r.operation === 'playAudit.decisionHistory').length;
await page.getByRole('button', { name: '결정 상세 다시 조회', exact: true }).click();
await expect(page.getByRole('list', { name: '판단 절차' })).toContainText('징병판정');
expect(requests.filter((r) => r.operation === 'playAudit.decisionHistory')).toHaveLength(count);
await capture(page, 'desktop-npc-decision');
});
@@ -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'
)
),
() => {