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
+ 가장 최근 결정이 수집된 월입니다. 현재 게임은 {{ history.currentMonth.year }}년 + {{ history.currentMonth.month }}월입니다. +
+각 장수의 턴 실행 후 저장됩니다. 월이 바뀌어도 해당 장수의 다음 턴 전까지는 이전 월 기록이 최신입니다.
+절차와 선택 결과를 수집한 기록입니다. 후보 내부 조건 전체는 아직 포함되지 않으며, 기록이 없다고 판단 시도가 없었다는 뜻은 아닙니다. @@ -164,7 +194,13 @@ watch(
{{ error }}
-이 월에 수집된 결정 기록이 없습니다.
++ {{ + history.selection === 'LATEST' + ? '이 장수의 현재 기수에 수집된 결정 기록이 없습니다.' + : '선택한 월에 수집된 결정 기록이 없습니다. 최근 결정 조회로 마지막 기록을 확인할 수 있습니다.' + }} +