NPC 감사에서 최근 기록 월을 기본 조회하고 실제 시나리오로 검증한다

This commit is contained in:
2026-09-26 17:37:32 +00:00
parent 6b65633324
commit aea5056834
10 changed files with 462 additions and 6 deletions
+31 -1
View File
@@ -173,7 +173,35 @@ export const decisionHistory = auditProcedure
.query(({ ctx, input }) =>
readAudit(ctx, async (tx) => {
const world = await readAuditWorld(tx);
const month = input.month ?? { year: world.year, month: world.month };
// 장수 턴은 월 경계와 동시에 실행되지 않는다. 현재 상태에서는 마지막으로
// 실제 결정이 저장된 월을 열고, 명시한 과거 월은 빈 월이어도 그대로 보존한다.
const latest =
!input.month && world.serverId
? await tx.playAuditDecision.findFirst({
where: {
serverId: world.serverId,
generalId: input.generalId,
phase: input.phase,
AND: [
{
OR: [
{ year: { gt: world.startYear } },
{ year: world.startYear, month: { gte: world.startMonth } },
],
},
{
OR: [
{ year: { lt: world.year } },
{ year: world.year, month: { lte: world.month } },
],
},
],
},
orderBy: [{ year: 'desc' }, { month: 'desc' }, { tick: 'desc' }, { id: 'desc' }],
select: { year: true, month: true },
})
: null;
const month = input.month ?? latest ?? { year: world.year, month: world.month };
const ordinal = monthOrdinal(month.year, month.month);
if (
ordinal < monthOrdinal(world.startYear, world.startMonth) ||
@@ -206,6 +234,8 @@ export const decisionHistory = auditProcedure
return {
...world,
month,
currentMonth: { year: world.year, month: world.month },
selection: input.month ? ('MONTH' as const) : ('LATEST' as const),
coverage: world.serverId ? ('PROCEDURES_ONLY' as const) : ('IDENTITY_MISSING' as const),
items: rows.slice(0, input.limit).map(project),
nextCursor: rows.length > input.limit && last ? { tick: last.tick.toString(), id: last.id } : null,
@@ -2352,6 +2352,33 @@ integration('game API security over HTTP transport', () => {
],
});
const decisionInput = { generalId: decisionGeneral, month: { year: 190, month: 1 }, limit: 1 };
// 월 경계 뒤 장수의 첫 턴 전에도 직전 결정에 도달할 수 있어야 한다.
expect((await get('decisionHistory', admin, { generalId: decisionGeneral })).body).toMatchObject({
result: {
data: {
selection: 'LATEST',
month: { year: 190, month: 1 },
currentMonth: { year: 190, month: 2 },
items: expect.arrayContaining([expect.objectContaining({ id: decisionIds[0] })]),
},
},
});
expect(
(await get('decisionHistory', admin, { generalId: decisionGeneral, month: { year: 190, month: 2 } }))
.body
).toMatchObject({
result: { data: { selection: 'MONTH', month: { year: 190, month: 2 }, items: [] } },
});
expect(
(await get('decisionHistory', admin, { generalId: decisionGeneral, phase: 'nation' })).body
).toMatchObject({
result: {
data: { selection: 'LATEST', month: { year: 190, month: 1 }, items: [{ id: decisionIds[1] }] },
},
});
expect((await get('decisionHistory', admin, { generalId: 2147483647 })).body).toMatchObject({
result: { data: { selection: 'LATEST', month: { year: 190, month: 2 }, items: [] } },
});
expect((await get('decisionHistory', undefined, decisionInput)).status).toBe(401);
expect((await get('decisionHistory', await token(['admin']), decisionInput)).status).toBe(403);
const decisionList = await get('decisionHistory', admin, decisionInput);
+42 -1
View File
@@ -147,7 +147,9 @@ const install = async (
case 'playAudit.decisionHistory':
return result({
...world,
month: input.month ?? { year: 190, month: 7 },
month: input.month ?? { year: 190, month: 6 },
currentMonth: { year: 190, month: 7 },
selection: input.month ? 'MONTH' : 'LATEST',
coverage: 'PROCEDURES_ONLY',
items: [
{
@@ -1608,3 +1610,42 @@ for (const width of [390, 1280]) {
await page.screenshot({ path: testInfo.outputPath('audit-gap.png'), fullPage: true });
});
}
for (const width of [390, 1280]) {
test(`NPC decision month navigation shows latest recorded month at ${width}px`, async ({ page }) => {
await page.setViewportSize({ width, height: 900 });
const requests = await install(page);
await page.goto(gamePath('/play-audit?tab=generals&general=1'));
await page.getByRole('button', { name: 'NPC 결정 기록 조회', exact: true }).click();
await expect(page.getByText('가장 최근 결정이 수집된 월입니다.', { exact: false })).toContainText('190년 7월');
const panel = page.getByRole('region', { name: 'NPC 결정 기록', exact: true });
await expect(panel).toContainText('190년 6월');
await panel.getByRole('button', { name: '다음 월', exact: true }).click();
await expect
.poll(() => requests.filter((r) => r.operation === 'playAudit.decisionHistory').at(-1)?.input)
.toMatchObject({ month: { year: 190, month: 7 } });
await expect(panel.getByRole('button', { name: '다음 월', exact: true })).toBeDisabled();
await panel.getByRole('button', { name: '이전 월', exact: true }).click();
await expect
.poll(() => requests.filter((r) => r.operation === 'playAudit.decisionHistory').at(-1)?.input)
.toMatchObject({ month: { year: 190, month: 6 } });
await panel.getByRole('button', { name: '최근 결정 조회', exact: true }).click();
await expect(page.getByText('가장 최근 결정이 수집된 월입니다.', { exact: false })).toBeVisible();
expect(requests.filter((r) => r.operation === 'playAudit.decisionHistory').at(-1)?.input).not.toHaveProperty(
'month'
);
await capture(page, `npc-decision-latest-${width}`);
});
}
test('direct decision URL keeps the history panel open when changing month', async ({ page }) => {
await install(page);
await page.goto(gamePath(`/play-audit?tab=generals&general=1&decision=${decision.id}`));
const panel = page.getByRole('region', { name: 'NPC 결정 기록', exact: true });
await expect(panel.getByRole('list', { name: '판단 절차' })).toBeVisible();
await panel.getByRole('button', { name: '이전 월', exact: true }).click();
await expect(panel).toBeVisible();
await expect(panel.getByRole('region', { name: '선택 결정 상세' })).toHaveCount(0);
await panel.getByRole('button', { name: '최근 결정 조회', exact: true }).click();
await expect(panel).toContainText('가장 최근 결정이 수집된 월입니다.');
});
@@ -15,6 +15,12 @@ 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;
@@ -41,13 +47,14 @@ const load = async (more = false) => {
try {
const response = await trpc.playAudit.decisionHistory.query({
generalId: props.generalId,
month: more ? (history.value?.month ?? props.month) : props.month,
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) {
@@ -56,6 +63,18 @@ const load = async (more = false) => {
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;
@@ -130,6 +149,7 @@ watch(
[() => props.generalId, () => props.month?.year, () => props.month?.month],
() => {
generation++;
selectedMonth.value = props.month;
history.value = null;
error.value = '';
loading.value = false;
@@ -156,6 +176,16 @@ watch(
{{ 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>
절차와 선택 결과를 수집한 기록입니다. 후보 내부 조건 전체는 아직 포함되지 않으며, 기록이 없다고 판단 시도가
없었다는 뜻은 아닙니다.
@@ -164,7 +194,13 @@ watch(
<p v-if="error" role="alert">
{{ error }} <button class="legacy-button" @click="load()">결정 목록 다시 조회</button>
</p>
<p v-if="history && !history.items.length">이 월에 수집된 결정 기록이 없습니다.</p>
<p v-if="history && !history.items.length">
{{
history.selection === 'LATEST'
? '이 장수의 현재 기수에 수집된 결정 기록이 없습니다.'
: '선택한 월에 수집된 결정 기록이 없습니다. 최근 결정 조회로 마지막 기록을 확인할 수 있습니다.'
}}
</p>
<div v-if="history?.items.length" class="table-scroll">
<table>
<thead>
@@ -22,6 +22,15 @@ const showLogs = ref(false);
const route = useRoute();
const router = useRouter();
const showDecisions = ref(false);
// 상세 URL로 들어온 경우에도 목록의 열림 상태를 유지한다. 월 이동으로
// decision query만 지웠을 때 전체 결정 패널이 닫히면 안 된다.
watch(
() => route.query.decision,
(decision) => {
if (typeof decision === 'string') showDecisions.value = true;
},
{ immediate: true }
);
const decisionsOpen = computed(() => showDecisions.value || typeof route.query.decision === 'string');
const toggleDecisions = async () => {
if (decisionsOpen.value) {