From aea50568346753831b4f6ce0e7c4596e70b69473 Mon Sep 17 00:00:00 2001
From: hided62
Date: Sat, 26 Sep 2026 17:37:32 +0000
Subject: [PATCH] =?UTF-8?q?NPC=20=EA=B0=90=EC=82=AC=EC=97=90=EC=84=9C=20?=
=?UTF-8?q?=EC=B5=9C=EA=B7=BC=20=EA=B8=B0=EB=A1=9D=20=EC=9B=94=EC=9D=84=20?=
=?UTF-8?q?=EA=B8=B0=EB=B3=B8=20=EC=A1=B0=ED=9A=8C=ED=95=98=EA=B3=A0=20?=
=?UTF-8?q?=EC=8B=A4=EC=A0=9C=20=EC=8B=9C=EB=82=98=EB=A6=AC=EC=98=A4?=
=?UTF-8?q?=EB=A1=9C=20=EA=B2=80=EC=A6=9D=ED=95=9C=EB=8B=A4?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../src/router/playAudit/decisions.ts | 32 +++-
.../securityTransport.integration.test.ts | 27 ++++
app/game-frontend/e2e/playAudit.spec.ts | 43 +++++-
.../playAudit/AuditGeneralDecisions.vue | 40 ++++-
.../playAudit/AuditGeneralDetail.vue | 9 ++
docs/design/play-audit-implementation.md | 8 +-
docs/play-audit-operations.md | 34 +++++
.../play-audit-npc.playwright.config.mjs | 38 +++++
.../play-audit-npc.spec.ts | 93 +++++++++++
.../scripts/play-audit-npc-lifecycle.ts | 144 ++++++++++++++++++
10 files changed, 462 insertions(+), 6 deletions(-)
create mode 100644 tools/frontend-legacy-parity/play-audit-npc.playwright.config.mjs
create mode 100644 tools/frontend-legacy-parity/play-audit-npc.spec.ts
create mode 100644 tools/integration-tests/scripts/play-audit-npc-lifecycle.ts
diff --git a/app/game-api/src/router/playAudit/decisions.ts b/app/game-api/src/router/playAudit/decisions.ts
index ab3a1251..af604ed2 100644
--- a/app/game-api/src/router/playAudit/decisions.ts
+++ b/app/game-api/src/router/playAudit/decisions.ts
@@ -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,
diff --git a/app/game-api/test/securityTransport.integration.test.ts b/app/game-api/test/securityTransport.integration.test.ts
index f1af31f4..2e00a6a5 100644
--- a/app/game-api/test/securityTransport.integration.test.ts
+++ b/app/game-api/test/securityTransport.integration.test.ts
@@ -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);
diff --git a/app/game-frontend/e2e/playAudit.spec.ts b/app/game-frontend/e2e/playAudit.spec.ts
index 4c3e5f43..bd2cc2d2 100644
--- a/app/game-frontend/e2e/playAudit.spec.ts
+++ b/app/game-frontend/e2e/playAudit.spec.ts
@@ -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('가장 최근 결정이 수집된 월입니다.');
+});
diff --git a/app/game-frontend/src/components/playAudit/AuditGeneralDecisions.vue b/app/game-frontend/src/components/playAudit/AuditGeneralDecisions.vue
index 452f897a..36cf319c 100644
--- a/app/game-frontend/src/components/playAudit/AuditGeneralDecisions.vue
+++ b/app/game-frontend/src/components/playAudit/AuditGeneralDecisions.vue
@@ -15,6 +15,12 @@ const detail = ref(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·유저 자동턴의 개인/수뇌
판단
+
+ 가장 최근 결정이 수집된 월입니다. 현재 게임은 {{ history.currentMonth.year }}년
+ {{ history.currentMonth.month }}월입니다.
+
+ 각 장수의 턴 실행 후 저장됩니다. 월이 바뀌어도 해당 장수의 다음 턴 전까지는 이전 월 기록이 최신입니다.
+
절차와 선택 결과를 수집한 기록입니다. 후보 내부 조건 전체는 아직 포함되지 않으며, 기록이 없다고 판단 시도가
없었다는 뜻은 아닙니다.
@@ -164,7 +194,13 @@ watch(
{{ error }}
- 이 월에 수집된 결정 기록이 없습니다.
+
+ {{
+ history.selection === 'LATEST'
+ ? '이 장수의 현재 기수에 수집된 결정 기록이 없습니다.'
+ : '선택한 월에 수집된 결정 기록이 없습니다. 최근 결정 조회로 마지막 기록을 확인할 수 있습니다.'
+ }}
+