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 }) => .query(({ ctx, input }) =>
readAudit(ctx, async (tx) => { readAudit(ctx, async (tx) => {
const world = await readAuditWorld(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); const ordinal = monthOrdinal(month.year, month.month);
if ( if (
ordinal < monthOrdinal(world.startYear, world.startMonth) || ordinal < monthOrdinal(world.startYear, world.startMonth) ||
@@ -206,6 +234,8 @@ export const decisionHistory = auditProcedure
return { return {
...world, ...world,
month, 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), coverage: world.serverId ? ('PROCEDURES_ONLY' as const) : ('IDENTITY_MISSING' as const),
items: rows.slice(0, input.limit).map(project), items: rows.slice(0, input.limit).map(project),
nextCursor: rows.length > input.limit && last ? { tick: last.tick.toString(), id: last.id } : null, 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 }; 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', undefined, decisionInput)).status).toBe(401);
expect((await get('decisionHistory', await token(['admin']), decisionInput)).status).toBe(403); expect((await get('decisionHistory', await token(['admin']), decisionInput)).status).toBe(403);
const decisionList = await get('decisionHistory', admin, decisionInput); const decisionList = await get('decisionHistory', admin, decisionInput);
+42 -1
View File
@@ -147,7 +147,9 @@ const install = async (
case 'playAudit.decisionHistory': case 'playAudit.decisionHistory':
return result({ return result({
...world, ...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', coverage: 'PROCEDURES_ONLY',
items: [ items: [
{ {
@@ -1608,3 +1610,42 @@ for (const width of [390, 1280]) {
await page.screenshot({ path: testInfo.outputPath('audit-gap.png'), fullPage: true }); 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 error = ref('');
const detailError = ref(''); const detailError = ref('');
const loading = ref(false); 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); const detailLoading = ref(false);
let generation = 0; let generation = 0;
let detailGeneration = 0; let detailGeneration = 0;
@@ -41,13 +47,14 @@ const load = async (more = false) => {
try { try {
const response = await trpc.playAudit.decisionHistory.query({ const response = await trpc.playAudit.decisionHistory.query({
generalId: props.generalId, generalId: props.generalId,
month: more ? (history.value?.month ?? props.month) : props.month, month: more ? (history.value?.month ?? selectedMonth.value) : selectedMonth.value,
limit: 50, limit: 50,
cursor: more ? (history.value?.nextCursor ?? undefined) : undefined, cursor: more ? (history.value?.nextCursor ?? undefined) : undefined,
}); });
if (request === generation) if (request === generation)
history.value = { history.value = {
...response, ...response,
selection: more ? (history.value?.selection ?? response.selection) : response.selection,
items: more ? [...(history.value?.items ?? []), ...response.items] : response.items, items: more ? [...(history.value?.items ?? []), ...response.items] : response.items,
}; };
} catch (cause) { } catch (cause) {
@@ -56,6 +63,18 @@ const load = async (more = false) => {
if (request === generation) loading.value = 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) => { const loadDetail = async (more = false) => {
if (!selected.value || detailLoading.value) return; if (!selected.value || detailLoading.value) return;
const request = detailGeneration; const request = detailGeneration;
@@ -130,6 +149,7 @@ watch(
[() => props.generalId, () => props.month?.year, () => props.month?.month], [() => props.generalId, () => props.month?.year, () => props.month?.month],
() => { () => {
generation++; generation++;
selectedMonth.value = props.month;
history.value = null; history.value = null;
error.value = ''; error.value = '';
loading.value = false; loading.value = false;
@@ -156,6 +176,16 @@ watch(
{{ history ? `${history.month.year}년 ${history.month.month}월` : '선택 월' }} · NPC·유저 자동턴의 개인/수뇌 {{ history ? `${history.month.year}년 ${history.month.month}월` : '선택 월' }} · NPC·유저 자동턴의 개인/수뇌
판단 판단
</p> </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> <p>
절차와 선택 결과를 수집한 기록입니다. 후보 내부 조건 전체는 아직 포함되지 않으며, 기록이 없다고 판단 시도가 절차와 선택 결과를 수집한 기록입니다. 후보 내부 조건 전체는 아직 포함되지 않으며, 기록이 없다고 판단 시도가
없었다는 뜻은 아닙니다. 없었다는 뜻은 아닙니다.
@@ -164,7 +194,13 @@ watch(
<p v-if="error" role="alert"> <p v-if="error" role="alert">
{{ error }} <button class="legacy-button" @click="load()">결정 목록 다시 조회</button> {{ error }} <button class="legacy-button" @click="load()">결정 목록 다시 조회</button>
</p> </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"> <div v-if="history?.items.length" class="table-scroll">
<table> <table>
<thead> <thead>
@@ -22,6 +22,15 @@ const showLogs = ref(false);
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const showDecisions = ref(false); 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 decisionsOpen = computed(() => showDecisions.value || typeof route.query.decision === 'string');
const toggleDecisions = async () => { const toggleDecisions = async () => {
if (decisionsOpen.value) { if (decisionsOpen.value) {
+6 -2
View File
@@ -73,7 +73,12 @@ API는 허용 필드로 투영하고 UI는 기존 정책 한글 label을 재사
### NPC 결정 조회 API와 화면 ### NPC 결정 조회 API와 화면
`playAudit.decisionHistory/decisionDetail`은 같은 프로필 감사 권한·현재 기수 경계를 따른다. `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를 목록은50건 기본/200건 상한으로 요약만 읽고, 상세는 명시 선택 시128 event chunk를
기본1개/최대4개 읽는다. header의 stepCount로 다음 chunk를 판단해 추가 본문이나 COUNT를 기본1개/최대4개 읽는다. header의 stepCount로 다음 chunk를 판단해 추가 본문이나 COUNT를
읽지 않는다. summary/step은 허용 필드만 투영하며 seed/raw metadata를 반환하지 않는다. 읽지 않는다. summary/step은 허용 필드만 투영하며 seed/raw metadata를 반환하지 않는다.
@@ -690,7 +695,6 @@ transaction의 `world_state.meta.playAuditFlows`를 읽는다. 국가·자원·
필요한 line 구성요소만 등록하고 resize·unmount를 처리하며 null 구간을 연결하지 않는다. 필요한 line 구성요소만 등록하고 resize·unmount를 처리하며 null 구간을 연결하지 않는다.
차트와 수치 표는 같은 응답을 사용하고 집단/지표 전환은 추가 API를 호출하지 않는다. 차트와 수치 표는 같은 응답을 사용하고 집단/지표 전환은 추가 API를 호출하지 않는다.
## 2026-09-26 원래 목표 재점검 보완 ## 2026-09-26 원래 목표 재점검 보완
- `generals`는 금/쌀/병력/훈련/사기, 통솔/무력/지력, 경험/공헌, 병종 숙련도 5종의 - `generals`는 금/쌀/병력/훈련/사기, 통솔/무력/지력, 경험/공헌, 병종 숙련도 5종의
+34
View File
@@ -96,6 +96,40 @@ Gateway의 새 진입/권한 catalog도 사용하려면 같은 commit의 Gateway
화면의 0/null/미수집·부분 표시에 따라 해석한다. 감사 migration을 적용했다고 과거 NPC 화면의 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를 활성화하면 이전 기수 조회를 - 현재 기수 자료만 제공한다. RESET이 새 serverId를 활성화하면 이전 기수 조회를
@@ -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,
},
],
});
@@ -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('가장 최근 결정이 수집된 월입니다.');
}
});
}
@@ -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();
}
}