NPC 감사에서 최근 기록 월을 기본 조회하고 실제 시나리오로 검증한다
This commit is contained in:
@@ -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