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' + ? '이 장수의 현재 기수에 수집된 결정 기록이 없습니다.' + : '선택한 월에 수집된 결정 기록이 없습니다. 최근 결정 조회로 마지막 기록을 확인할 수 있습니다.' + }} +

diff --git a/app/game-frontend/src/components/playAudit/AuditGeneralDetail.vue b/app/game-frontend/src/components/playAudit/AuditGeneralDetail.vue index d4ba700c..1fadd21a 100644 --- a/app/game-frontend/src/components/playAudit/AuditGeneralDetail.vue +++ b/app/game-frontend/src/components/playAudit/AuditGeneralDetail.vue @@ -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) { diff --git a/docs/design/play-audit-implementation.md b/docs/design/play-audit-implementation.md index b5e2f748..c58ab6f8 100644 --- a/docs/design/play-audit-implementation.md +++ b/docs/design/play-audit-implementation.md @@ -73,7 +73,12 @@ API는 허용 필드로 투영하고 UI는 기존 정책 한글 label을 재사 ### NPC 결정 조회 API와 화면 `playAudit.decisionHistory/decisionDetail`은 같은 프로필 감사 권한·현재 기수 경계를 따른다. -장수별 조회는 선택 월(미지정은 현재 월), 선택 phase와 `(tick,id)` 내림차순 cursor를 사용한다. +장수별 조회는 선택 월과 phase, `(tick,id)` 내림차순 cursor를 사용한다. 월을 지정하지 +않으면 현재 기수·장수·phase의 가장 최근 결정이 있는 월을 인덱스로 1건 찾는다. +명시한 과거 월은 비어 있어도 다른 월로 바꾸지 않는다. API는 실제 조회 `month`, +현재 게임 `currentMonth`, `selection`을 구분하며 더보기는 최초 조회 월에 고정한다. +화면에는 턴 실행 후 저장되는 시점, 최근 기록 월/현재 월, 이전·다음 월과 최근 결정 +조회 버튼을 제공한다. 월말 표본과 달리 NPC 결정은 매 턴의 game flush에 저장된다. 목록은50건 기본/200건 상한으로 요약만 읽고, 상세는 명시 선택 시128 event chunk를 기본1개/최대4개 읽는다. header의 stepCount로 다음 chunk를 판단해 추가 본문이나 COUNT를 읽지 않는다. summary/step은 허용 필드만 투영하며 seed/raw metadata를 반환하지 않는다. @@ -690,7 +695,6 @@ transaction의 `world_state.meta.playAuditFlows`를 읽는다. 국가·자원· 필요한 line 구성요소만 등록하고 resize·unmount를 처리하며 null 구간을 연결하지 않는다. 차트와 수치 표는 같은 응답을 사용하고 집단/지표 전환은 추가 API를 호출하지 않는다. - ## 2026-09-26 원래 목표 재점검 보완 - `generals`는 금/쌀/병력/훈련/사기, 통솔/무력/지력, 경험/공헌, 병종 숙련도 5종의 diff --git a/docs/play-audit-operations.md b/docs/play-audit-operations.md index 39d5761f..780e8aa3 100644 --- a/docs/play-audit-operations.md +++ b/docs/play-audit-operations.md @@ -96,6 +96,40 @@ Gateway의 새 진입/권한 catalog도 사용하려면 같은 commit의 Gateway 화면의 0/null/미수집·부분 표시에 따라 해석한다. 감사 migration을 적용했다고 과거 NPC 결정이나 자원 이동이 자동으로 채워지는 것은 아니다. +## NPC 결정 시점과 실제 시나리오 검증 + +NPC 결정은 해당 장수의 개인·수뇌 AI 실행을 관측하고 게임 상태 flush와 함께 저장한다. +월말 표본을 기다리지 않는다. 월 경계와 장수별 턴 시각은 다르므로 새 월에 해당 장수의 +턴이 아직 오지 않았다면 이전 월 기록이 최신이다. 현재 장수에서 **NPC 결정 기록 조회**를 +열면 가장 최근 기록 월을 표시한다. 명시적으로 선택한 과거 월과 **이전 월/다음 월**은 +그 월만 조회하고, **최근 결정 조회**는 다시 최신 기록 월을 찾는다. 빈 목록은 선택 월에 +행이 없다는 뜻이며, 수집 장애 여부는 감사 화면의 이력 누락 표시와 함께 확인한다. +일반 장수의 개인 판단과 수뇌 직책 NPC의 수뇌 판단을 구분하며 AI를 실행하지 않은 +수동 명령에는 AI 결정 기록을 만들지 않는다. + +재현 도구는 `tools/integration-tests/scripts/play-audit-npc-lifecycle.ts`다. 격리된 개발 +DB/Redis와 새 `_npc_audit_lifecycle` suffix schema를 준비하고 정식 migration을 적용한다. +`DATABASE_URL`은 환경에서 전달하며 command line이나 artifact에 출력하지 않는다. +기존 world가 있으면 기본 실행을 거부한다. 완료한 전용 fixture는 같은 명령에 `--verify`를 +붙여 게임을 변경하지 않고 저장 결과만 다시 검증할 수 있다. 초기화나 기존 시즌 삭제 +도구로 사용하지 않는다. + +```sh +pnpm --filter @sammo-ts/infra prisma:migrate:deploy:game +pnpm exec tsx tools/integration-tests/scripts/play-audit-npc-lifecycle.ts +pnpm exec playwright test --config tools/frontend-legacy-parity/play-audit-npc.playwright.config.mjs +``` + +시나리오 2601을 180년부터 실제 production handler·fenced DB flush로 실행하며, +183년 이후 공백지 점령 완료와 n/m NPC 개인·수뇌 결정/chunk를 검증한다. 시간만 manual +clock으로 가속하고 결정·도시 소유를 직접 생성하지 않는다. `maxGenerals:20`의 작은 +flush batch로 감사의 시간 제한을 보존한다. 브라우저는 같은 DB의 실제 API를 사용하며 +결정 응답을 mock하지 않는다. 기본 port는 frontend15301/API15302이며 각각 +`NPC_AUDIT_FRONTEND_PORT`, `NPC_AUDIT_API_PORT`로 격리한다. API 실행에는 개발용 +`REDIS_URL`, `GAME_TOKEN_SECRET`, `GAME_IMAGE_UPLOAD_SECRET_FILE`도 필요하다. +결과 JSON은 `test-results/npc-audit-lifecycle/`, 화면·geometry는 +`test-results/npc-audit-browser/`에 보존한다. 세션 token은 artifact에 남기지 않는다. + ## 보존과 미완성 범위 - 현재 기수 자료만 제공한다. RESET이 새 serverId를 활성화하면 이전 기수 조회를 diff --git a/tools/frontend-legacy-parity/play-audit-npc.playwright.config.mjs b/tools/frontend-legacy-parity/play-audit-npc.playwright.config.mjs new file mode 100644 index 00000000..a0e20d15 --- /dev/null +++ b/tools/frontend-legacy-parity/play-audit-npc.playwright.config.mjs @@ -0,0 +1,38 @@ +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { defineConfig, devices } from '@playwright/test'; +const root = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); +const schema = new URL(process.env.DATABASE_URL ?? '').searchParams.get('schema'); +if (!schema || !/^[a-z0-9_]+_npc_audit_lifecycle$/.test(schema)) throw new Error('Dedicated lifecycle DB required'); +const frontendPort = Number(process.env.NPC_AUDIT_FRONTEND_PORT ?? 15301); +const apiPort = Number(process.env.NPC_AUDIT_API_PORT ?? 15302); +const frontendEnv = `VITE_APP_BASE_PATH=/che VITE_GAME_PROFILE=che:default VITE_GAME_API_URL=http://127.0.0.1:${apiPort}/che/api/trpc`; +export default defineConfig({ + testDir: '.', + testMatch: 'play-audit-npc.spec.ts', + workers: 1, + timeout: 60000, + outputDir: resolve(root, 'test-results/npc-audit-browser'), + use: { + ...devices['Desktop Chrome'], + baseURL: `http://127.0.0.1:${frontendPort}/che/`, + locale: 'ko-KR', + timezoneId: 'UTC', + deviceScaleFactor: 1, + trace: 'off', + }, + webServer: [ + { + command: `GAME_API_ROLE=server GAME_API_HOST=127.0.0.1 GAME_API_PORT=${apiPort} GAME_TRPC_PATH=/che/api/trpc GAME_API_EVENTS_PATH=/che/api/events PROFILE=${schema} SCENARIO=default GAME_PROFILE_NAME=che:default node app/game-api/dist/index.js`, + cwd: root, + url: `http://127.0.0.1:${apiPort}/che/api/trpc/health.ping`, + timeout: 120000, + }, + { + command: `${frontendEnv} pnpm --filter @sammo-ts/game-frontend build && ${frontendEnv} pnpm --filter @sammo-ts/game-frontend preview --host 127.0.0.1 --port ${frontendPort}`, + cwd: root, + url: `http://127.0.0.1:${frontendPort}/che/`, + timeout: 120000, + }, + ], +}); diff --git a/tools/frontend-legacy-parity/play-audit-npc.spec.ts b/tools/frontend-legacy-parity/play-audit-npc.spec.ts new file mode 100644 index 00000000..e2c90cbb --- /dev/null +++ b/tools/frontend-legacy-parity/play-audit-npc.spec.ts @@ -0,0 +1,93 @@ +import { randomUUID } from 'node:crypto'; +import { readFile, writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { expect, test } from '@playwright/test'; +import { createRedisConnector, resolveRedisConfigFromEnv } from '../../packages/infra/src/index.js'; + +const root = resolve(import.meta.dirname, '../..'); +const token = `ga_${randomUUID()}`; +const profile = 'che:default'; +const accessKey = `sammo:game:access:${profile}:${token}`; +const evidencePath = resolve(root, process.env.NPC_AUDIT_OUTPUT ?? 'test-results/npc-audit-lifecycle', 'evidence.json'); +let evidence: { + subjects: Array<{ + generalId: number; + npcState: number; + phase: string; + year: number; + month: number; + id: string; + steps: number; + }>; +}; +const redis = createRedisConnector(resolveRedisConfigFromEnv()); +test.beforeAll(async () => { + const url = process.env.DATABASE_URL; + if (!url || !new URL(url).searchParams.get('schema')?.endsWith('_npc_audit_lifecycle')) + throw new Error('Dedicated lifecycle DB required'); + evidence = JSON.parse(await readFile(evidencePath, 'utf8')); + await redis.connect(); + await redis.client.set( + accessKey, + JSON.stringify({ + version: 1, + profile, + issuedAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + 3600000).toISOString(), + sessionId: randomUUID(), + user: { + id: 'npc-audit-reviewer', + username: 'npc-audit-reviewer', + displayName: '감사 검증', + roles: ['admin.playAudit.read:che:default'], + }, + sanctions: {}, + }), + { EX: 3600 } + ); +}); +test.afterAll(async () => { + await redis.client.del(accessKey); + await redis.disconnect(); +}); +for (const width of [390, 1280]) { + test(`real NPC chief and general decisions after conquest at ${width}px`, async ({ page }, testInfo) => { + await page.setViewportSize({ width, height: 900 }); + await page.addInitScript( + ({ token }) => { + localStorage.setItem('sammo-game-token', token); + localStorage.setItem('sammo-game-profile', 'che:default'); + }, + { token } + ); + for (const subject of evidence.subjects) { + await page.goto(`play-audit?tab=generals&general=${subject.generalId}&decision=${subject.id}`); + const panel = page.getByRole('region', { name: 'NPC 결정 기록', exact: true }); + await expect(panel.getByRole('list', { name: '판단 절차' })).toBeVisible(); + await expect(panel).toContainText(`${subject.year}년 ${subject.month}월`); + await expect(panel.getByRole('list', { name: '판단 절차' })).toContainText('판단 시작'); + const phase = subject.phase === 'nation' ? '수뇌 판단' : '개인 판단'; + await expect(panel.getByRole('button', { name: new RegExp(`^${phase} · tick`) }).first()).toBeVisible(); + await page.evaluate(() => document.fonts.ready); + const geometry = await panel.evaluate((element) => ({ + rect: element.getBoundingClientRect().toJSON(), + font: getComputedStyle(element).fontSize, + buttons: [...element.querySelectorAll('nav button')].map((button) => ({ + text: button.textContent, + rect: button.getBoundingClientRect().toJSON(), + disabled: (button as HTMLButtonElement).disabled, + })), + })); + const name = `npc-${subject.npcState}-${subject.phase}`; + await writeFile(testInfo.outputPath(`${name}.json`), JSON.stringify(geometry, null, 2)); + await panel.screenshot({ path: testInfo.outputPath(`${name}.png`) }); + await panel.getByRole('button', { name: '이전 월', exact: true }).click(); + await expect(panel.getByRole('region', { name: '선택 결정 상세' })).toHaveCount(0); + await panel.getByRole('button', { name: '최근 결정 조회', exact: true }).click(); + await expect(panel).toContainText('가장 최근 결정이 수집된 월입니다.'); + await page.reload(); + await page.getByRole('button', { name: 'NPC 결정 기록 조회', exact: true }).click(); + await expect(panel).toContainText('가장 최근 결정이 수집된 월입니다.'); + } + }); +} diff --git a/tools/integration-tests/scripts/play-audit-npc-lifecycle.ts b/tools/integration-tests/scripts/play-audit-npc-lifecycle.ts new file mode 100644 index 00000000..f724cb4c --- /dev/null +++ b/tools/integration-tests/scripts/play-audit-npc-lifecycle.ts @@ -0,0 +1,144 @@ +/** Dedicated real-DB scenario run. No fabricated decisions or direct city ownership edits. */ +import assert from 'node:assert/strict'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { asRecord, type TurnCheckpoint } from '@sammo-ts/common'; +import { createGamePostgresConnector } from '@sammo-ts/infra'; +import { createTurnDaemonRuntime, seedScenarioToDatabase } from '@sammo-ts/game-engine'; + +const databaseUrl = process.env.DATABASE_URL; +assert( + databaseUrl && new URL(databaseUrl).searchParams.get('schema')?.endsWith('_npc_audit_lifecycle'), + 'Dedicated _npc_audit_lifecycle schema required' +); +const output = resolve(process.env.NPC_AUDIT_OUTPUT ?? 'test-results/npc-audit-lifecycle'); +await mkdir(output, { recursive: true }); +const connector = createGamePostgresConnector({ url: databaseUrl }); +await connector.connect(); +const db = connector.prisma; +const verify = async (progress: unknown[]) => { + const state = await db.worldState.findFirstOrThrow(); + assert.equal(asRecord(state.meta).serverId, 'npc-audit-lifecycle-2601'); + assert(state.currentYear >= 183); + assert.equal(asRecord(state.meta).playAuditGap, undefined, 'Audit dropped a batch'); + assert.equal( + await db.city.count({ where: { nationId: 0, level: { gt: 0 } } }), + 0, + 'Empty-land conquest incomplete' + ); + const subjects = []; + // 실제 임명된 수뇌만 국가 AI를 실행한다. 모든 NPC 종류에 수뇌직을 강제로 부여하지 않는다. + for (const selection of [ + { npcState: 2, phase: 'general' }, + { npcState: 3, phase: 'general' }, + { npcState: { in: [2, 3] }, phase: 'nation' }, + ]) { + const row = await db.playAuditDecision.findFirst({ + where: { ...selection, year: { gte: 183 } }, + orderBy: [{ year: 'desc' }, { month: 'desc' }, { tick: 'desc' }], + include: { chunks: { orderBy: { ordinal: 'asc' } } }, + }); + assert(row && row.chunks.length, `Missing actual NPC ${JSON.stringify(selection)} decision`); + const steps = row.chunks.flatMap((chunk) => (Array.isArray(chunk.steps) ? chunk.steps : [])); + assert.equal(steps.length, row.stepCount); + assert.equal(asRecord(steps[0]).kind, 'DECISION_START'); + assert(steps.some((step) => asRecord(step).kind === 'DECISION_END')); + assert.equal(asRecord(steps.at(-1)).kind, 'EXECUTION_ATTEMPT'); + subjects.push({ + generalId: row.generalId, + npcState: row.npcState, + phase: row.phase, + year: row.year, + month: row.month, + id: row.id, + steps: row.stepCount, + }); + } + const evidence = { + scenario: 2601, + serverId: 'npc-audit-lifecycle-2601', + year: state.currentYear, + month: state.currentMonth, + decisions: await db.playAuditDecision.count(), + progress, + subjects, + }; + await writeFile(resolve(output, 'evidence.json'), JSON.stringify(evidence, null, 2)); + console.log(JSON.stringify({ success: true, ...evidence, progress: undefined })); +}; +if (process.argv.includes('--verify')) { + try { + const progress = JSON.parse( + await readFile(resolve(output, 'progress.json'), 'utf8').catch(() => '[]') + ) as unknown[]; + await verify(progress); + } finally { + await connector.disconnect(); + } +} else { + assert.equal(await db.worldState.count(), 0, 'Use a fresh migrated schema; existing seasons must be preserved'); + process.env.INTEGRATION_WORLD_SEED = 'npc-audit-lifecycle-2601-v1'; + await seedScenarioToDatabase({ + scenarioId: 2601, + databaseUrl, + gameClockMode: 'manual', + now: new Date('2026-09-26T00:00:00Z'), + installOptions: { + turnTermMinutes: 5, + sync: false, + npcMode: 2, + serverId: 'npc-audit-lifecycle-2601', + tournamentTrig: false, + }, + }); + const runtime = await createTurnDaemonRuntime({ + profile: 'che:default', + databaseUrl, + gameClockMode: 'manual', + enableLeaseHeartbeat: true, + leaseDurationMs: 300_000, + exclusiveFastForward: true, + databaseTransactionTimeoutMs: 60_000, + }); + const progress = []; + try { + for (let iteration = 0; iteration < 120; iteration++) { + const before = runtime.world.getState(); + const target = new Date(before.lastTurnTime.getTime() + before.tickSeconds * 1000); + runtime.world.advanceGameClockTo(target, new Date()); + let checkpoint: TurnCheckpoint | undefined; + do { + const result = await runtime.processor.run( + target, + { budgetMs: 1000, maxGenerals: 20, catchUpCap: 1 }, + checkpoint + ); + await runtime.hooks?.flushChanges?.(result); + checkpoint = result.checkpoint; + assert(!result.partial || result.processedGenerals > 0 || result.processedTurns > 0, 'No progress'); + } while (checkpoint); + const state = runtime.world.getState(); + const neutral = runtime.world.listCities().filter((city) => city.nationId === 0 && city.level > 0).length; + const row = { + year: state.currentYear, + month: state.currentMonth, + neutral, + generals: runtime.world.listGenerals().length, + decisions: await db.playAuditDecision.count(), + }; + progress.push(row); + await writeFile(resolve(output, 'progress.json'), JSON.stringify(progress, null, 2)); + if (iteration % 6 === 0 || state.currentYear >= 183) console.log(JSON.stringify(row)); + assert.equal( + asRecord((await db.worldState.findFirstOrThrow()).meta).playAuditGap, + undefined, + 'Audit dropped a batch' + ); + if (state.currentYear >= 184 && neutral === 0) break; + } + await verify(progress); + } finally { + await runtime.close(); + await connector.disconnect(); + } +}