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
@@ -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();
}
}