NPC 감사에서 최근 기록 월을 기본 조회하고 실제 시나리오로 검증한다
This commit is contained in:
@@ -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);
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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종의
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user