import { execFileSync, spawnSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; import { LiteHashDRBG, RandUtil } from '@sammo-ts/common'; import { seedScenarioToDatabase } from '@sammo-ts/game-engine'; import { buildNpcSelectionTokenSeed, chooseNpcPossessionCandidates, reserveNpcPossessionCandidates, type NpcPossessionCandidate, } from '@sammo-ts/game-engine/turn/npcPossessionService.js'; import { createGamePostgresConnector, type GamePrisma, type GamePrismaClient } from '@sammo-ts/infra'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { findTurnDifferentialWorkspaceRoot } from '../src/turn-differential/referenceSnapshot.js'; type FixtureCandidate = { id: number; leadership: number; strength: number; intel: number; }; type FixturePick = { id: number; keepCount: number; }; type FixtureCase = { name: string; reservedIds: number[]; boundaryReservedIds?: number[]; expiredReservedIds?: number[]; hasPreviousToken?: boolean; previousPick?: FixturePick[]; keepIds?: number[]; }; type Fixture = { hiddenSeed: string | number; owner: number; now: string; candidates: FixtureCandidate[]; cases: FixtureCase[]; }; type KernelTrace = { name: string; cancelled: boolean; seed: string | null; candidateOrder: number[]; randomDraws: number[]; draws: number[]; pick: FixturePick[]; }; type ReferenceKernelTrace = KernelTrace & { selectionStateUnchanged: boolean; }; type ReferenceTrace = { fixtureGeneralIds: number[]; observedSqlOrder: number[]; cases: ReferenceKernelTrace[]; }; type CoreReservationTrace = { pick: FixturePick[]; randomDraws: number[]; draws: number[]; tokenBefore: unknown; tokenAfter: unknown; }; const configuredWorkspaceRoot = process.env.TURN_DIFFERENTIAL_WORKSPACE_ROOT; const workspaceRoot = configuredWorkspaceRoot ?? findTurnDifferentialWorkspaceRoot(process.cwd()); const databaseUrl = process.env.NPC_POSSESSION_DIFFERENTIAL_DATABASE_URL; const integration = describe.skipIf(!workspaceRoot || !databaseUrl || process.env.TURN_DIFFERENTIAL_REFERENCE !== '1'); const ownerUserId = 'npc-possession-differential-owner'; const reservedOwnerUserId = 'npc-possession-differential-reserved'; const fixturePath = (): string => process.env.NPC_POSSESSION_DIFFERENTIAL_FIXTURE ?? path.join(workspaceRoot!, 'docker_compose_files/reference/fixtures/npc-possession-differential/selector.json'); const readFixture = (): Fixture => JSON.parse(fs.readFileSync(fixturePath(), 'utf8')) as Fixture; const referenceSourceRoot = (): string => path.resolve(process.env.REF_COMPARE_SOURCE_ROOT ?? path.join(workspaceRoot!, 'ref/sam')); const referenceRunner = (): string => path.join(referenceSourceRoot(), 'hwe/compare/npc_possession_selection_trace.php'); const referenceTimeoutMs = (): number => { const value = Number(process.env.NPC_POSSESSION_DIFFERENTIAL_TIMEOUT_MS ?? '60000'); if (!Number.isSafeInteger(value) || value < 5_000 || value > 600_000) { throw new Error('NPC_POSSESSION_DIFFERENTIAL_TIMEOUT_MS must be an integer from 5000 to 600000'); } return value; }; const referenceComparisonTestTimeoutMs = (): number => referenceTimeoutMs() * 2 + 60_000; const runReference = (fixture: string): ReferenceTrace => { const stackDirectory = path.resolve( process.env.TURN_DIFFERENTIAL_STACK_DIR ?? path.join(workspaceRoot!, 'docker_compose_files/reference') ); const runner = referenceRunner(); const stdout = execFileSync('./scripts/run-turn-differential-case.sh', ['-'], { cwd: stackDirectory, input: fixture, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], timeout: referenceTimeoutMs(), env: { ...process.env, TURN_DIFFERENTIAL_RUNNER_SCRIPT: runner, TURN_DIFFERENTIAL_COMPARE_DIR: path.dirname(runner), TURN_DIFFERENTIAL_APP_DIR: referenceSourceRoot(), TURN_DIFFERENTIAL_RUNTIME_DIR: path.join(workspaceRoot!, 'ref/sam'), TURN_DIFFERENTIAL_STACK_DIR: stackDirectory, }, }); return JSON.parse(stdout) as ReferenceTrace; }; const toCandidate = (candidate: FixtureCandidate, keepCount = 3): NpcPossessionCandidate => ({ id: candidate.id, name: `후보${candidate.id}`, nation: { id: 0, name: '재야', color: '#666666' }, stats: { leadership: candidate.leadership, strength: candidate.strength, intelligence: candidate.intel, }, picture: null, imageServer: 0, personality: { code: 'None', name: '-', info: '없음' }, specialDomestic: { code: 'None', name: '-', info: '없음' }, specialWar: { code: 'None', name: '-', info: '없음' }, keepCount, }); // Ref serializes an insertion-ordered PHP object while JavaScript enumerates // integer-like object keys numerically. The persisted/API pick is a set; draw // order is compared independently and exactly below. const normalizePickSet = (pick: FixturePick[]): FixturePick[] => [...pick].sort((left, right) => left.id - right.id || left.keepCount - right.keepCount); const acceptedAt = (fixture: Fixture): Date => new Date(`${fixture.now.replace(' ', 'T')}+09:00`); const buildPreviousCandidates = (fixture: Fixture, previous: FixturePick[]): Record => { const byId = new Map(fixture.candidates.map((candidate) => [candidate.id, candidate])); return Object.fromEntries( previous.map(({ id, keepCount }) => { const candidate = byId.get(id); if (!candidate) { throw new Error(`Unknown previous candidate ${id}`); } return [String(id), toCandidate(candidate, keepCount)]; }) ); }; class TracingRandUtil extends RandUtil { public readonly floatDraws: number[] = []; public override nextFloat1(): number { const value = super.nextFloat1(); this.floatDraws.push(value); return value; } } const runCoreKernelCase = (fixture: Fixture, testCase: FixtureCase): KernelTrace => { const reserved = new Set([...testCase.reservedIds, ...(testCase.boundaryReservedIds ?? [])]); const candidates = fixture.candidates .filter(({ id }) => !reserved.has(id)) .sort((left, right) => left.id - right.id) .map((candidate) => toCandidate(candidate)); const previous = buildPreviousCandidates(fixture, testCase.previousPick ?? []); const keepIds = new Set(testCase.keepIds ?? []); const kept = Object.fromEntries( Object.entries(previous) .filter(([, candidate]) => keepIds.has(candidate.id) && candidate.keepCount > 0) .map(([id, candidate]) => [id, { ...candidate, keepCount: candidate.keepCount - 1 }]) ); const hasPreviousToken = testCase.hasPreviousToken === true || Object.keys(previous).length > 0; if (hasPreviousToken && Object.keys(kept).length === Object.keys(previous).length) { return { name: testCase.name, cancelled: true, seed: null, candidateOrder: candidates.map(({ id }) => id), randomDraws: [], draws: [], pick: Object.values(previous).map(({ id, keepCount }) => ({ id, keepCount })), }; } const seed = buildNpcSelectionTokenSeed(fixture.hiddenSeed, fixture.owner, acceptedAt(fixture)); const rng = new TracingRandUtil(new LiteHashDRBG(seed)); const draws: number[] = []; const picked = chooseNpcPossessionCandidates(candidates, kept, rng, (selectedId) => { draws.push(Number(selectedId)); }); return { name: testCase.name, cancelled: false, seed, candidateOrder: candidates.map(({ id }) => id), randomDraws: rng.floatDraws, draws, pick: Object.values(picked).map(({ id, keepCount }) => ({ id, keepCount })), }; }; const assertDedicatedDatabase = (rawUrl: string): void => { const schema = new URL(rawUrl).searchParams.get('schema'); if (!schema?.endsWith('npc_possession_differential')) { throw new Error(`Refusing to mutate non-dedicated schema: ${schema ?? '(missing)'}`); } if (!/^[a-z0-9_]+$/.test(schema)) { throw new Error(`Refusing unsafe schema name: ${schema}`); } }; integration('NPC possession selector Ref differential', () => { let db: GamePrismaClient; let closeDb: (() => Promise) | undefined; let worldState: GamePrisma.WorldStateGetPayload>; const fixture = readFixture(); beforeAll(async () => { assertDedicatedDatabase(databaseUrl!); const previousSeed = process.env.INTEGRATION_WORLD_SEED; process.env.INTEGRATION_WORLD_SEED = String(fixture.hiddenSeed); try { await seedScenarioToDatabase({ scenarioId: 2, databaseUrl: databaseUrl!, now: acceptedAt(fixture), installOptions: { turnTermMinutes: 5, npcMode: 1, showImgLevel: 3, serverId: 'npc-possession-differential', season: 1, }, }); } finally { if (previousSeed === undefined) { delete process.env.INTEGRATION_WORLD_SEED; } else { process.env.INTEGRATION_WORLD_SEED = previousSeed; } } const connector = createGamePostgresConnector({ url: databaseUrl! }); await connector.connect(); db = connector.prisma; closeDb = () => connector.disconnect(); await db.inputEvent.deleteMany(); await db.npcSelectionToken.deleteMany(); await db.general.updateMany({ where: { userId: null, npcState: 2 }, data: { npcState: 1 }, }); const city = await db.city.findFirstOrThrow({ orderBy: { id: 'asc' } }); await db.general.createMany({ data: fixture.candidates.map((candidate) => ({ id: candidate.id, userId: null, name: `후보${candidate.id}`, nationId: 0, cityId: city.id, npcState: 2, leadership: candidate.leadership, strength: candidate.strength, intel: candidate.intel, turnTime: acceptedAt(fixture), personalCode: 'None', specialCode: 'None', special2Code: 'None', picture: null, imageServer: 0, meta: {}, penalty: {}, })), }); const eligibleIds = await db.general.findMany({ where: { userId: null, npcState: 2 }, orderBy: { id: 'asc' }, select: { id: true }, }); expect(eligibleIds.map(({ id }) => id)).toEqual( fixture.candidates.map(({ id }) => id).sort((left, right) => left - right) ); const seededWorld = await db.worldState.findFirstOrThrow(); const meta = typeof seededWorld.meta === 'object' && seededWorld.meta !== null && !Array.isArray(seededWorld.meta) ? seededWorld.meta : {}; worldState = await db.worldState.update({ where: { id: seededWorld.id }, data: { meta: { ...meta, hiddenSeed: fixture.hiddenSeed }, }, }); }, 60_000); afterAll(async () => { await closeDb?.(); }); const runCoreReservationCase = async (testCase: FixtureCase): Promise => db.$transaction(async (transaction) => { await transaction.npcSelectionToken.deleteMany(); const validUntil = new Date('2099-12-31T23:59:59.000Z'); const pickMoreFrom = new Date('2000-01-01T01:00:00.000Z'); const candidateById = new Map(fixture.candidates.map((candidate) => [candidate.id, candidate])); const insertReservedToken = async ( ownerId: string, ids: number[], reservationValidUntil: Date ): Promise => { if (ids.length === 0) return; const reservedPick = Object.fromEntries( ids.map((id) => { const candidate = candidateById.get(id); if (!candidate) throw new Error(`Unknown reserved candidate ${id}`); return [String(id), toCandidate(candidate)]; }) ); await transaction.npcSelectionToken.create({ data: { ownerUserId: ownerId, validUntil: reservationValidUntil, pickMoreFrom, pickResult: reservedPick as GamePrisma.InputJsonValue, nonce: 1, }, }); }; await insertReservedToken(reservedOwnerUserId, testCase.reservedIds, validUntil); await insertReservedToken( `${reservedOwnerUserId}-boundary`, testCase.boundaryReservedIds ?? [], acceptedAt(fixture) ); await insertReservedToken( `${reservedOwnerUserId}-expired`, testCase.expiredReservedIds ?? [], new Date(acceptedAt(fixture).getTime() - 1_000) ); const previousPick = testCase.previousPick ?? []; const hasPreviousToken = testCase.hasPreviousToken === true || previousPick.length > 0; if (hasPreviousToken) { await transaction.npcSelectionToken.create({ data: { ownerUserId, validUntil, pickMoreFrom, pickResult: buildPreviousCandidates(fixture, previousPick) as GamePrisma.InputJsonValue, nonce: 2, }, }); } const tokenBefore = await transaction.npcSelectionToken.findUnique({ where: { ownerUserId }, }); const randomDraws: number[] = []; const draws: number[] = []; const reservation = await reserveNpcPossessionCandidates({ db: transaction, worldState, userId: ownerUserId, ownerIdentity: fixture.owner, refresh: hasPreviousToken, keepIds: testCase.keepIds, now: acceptedAt(fixture), selectionObserver: { onRandomDraw: (value) => randomDraws.push(value), onCandidateDraw: (selectedId) => draws.push(Number(selectedId)), }, }); const tokenAfter = await transaction.npcSelectionToken.findUnique({ where: { ownerUserId }, }); return { pick: reservation.candidates.map(({ id, keepCount }) => ({ id, keepCount })), randomDraws, draws, tokenBefore, tokenAfter, }; }); it('rejects direct execution when the comparison guard is absent', () => { const result = spawnSync('php', [referenceRunner()], { input: fs.readFileSync(fixturePath(), 'utf8'), encoding: 'utf8', timeout: 5_000, env: { PATH: process.env.PATH, }, }); expect(result.status).toBe(64); expect(result.stdout).toBe(''); expect(result.stderr).toBe('NPC possession comparison is disabled.\n'); }); it( 'matches the shared Ref kernel and the actual Core reservation path', async () => { const fixtureText = fs.readFileSync(fixturePath(), 'utf8'); const firstReference = runReference(fixtureText); const secondReference = runReference(fixtureText); expect(secondReference).toEqual(firstReference); expect(firstReference.fixtureGeneralIds).toEqual(fixture.candidates.map(({ id }) => id)); expect(firstReference.observedSqlOrder).toEqual( fixture.candidates.map(({ id }) => id).sort((left, right) => left - right) ); expect(firstReference.cases).toHaveLength(fixture.cases.length); const coreKernelCases = fixture.cases.map((testCase) => runCoreKernelCase(fixture, testCase)); for (const [index, referenceCase] of firstReference.cases.entries()) { const testCase = fixture.cases[index]!; const coreKernel = coreKernelCases[index]!; const coreReservation = await runCoreReservationCase(testCase); expect(referenceCase.name).toBe(coreKernel.name); expect(referenceCase.selectionStateUnchanged).toBe(true); expect(referenceCase.cancelled).toBe(coreKernel.cancelled); expect(referenceCase.seed).toBe(coreKernel.seed); expect(referenceCase.candidateOrder).toEqual(coreKernel.candidateOrder); expect(referenceCase.randomDraws).toEqual(coreKernel.randomDraws); expect(referenceCase.draws).toEqual(coreKernel.draws); expect(referenceCase.randomDraws).toEqual(coreReservation.randomDraws); expect(referenceCase.draws).toEqual(coreReservation.draws); expect(normalizePickSet(referenceCase.pick)).toEqual(normalizePickSet(coreKernel.pick)); expect(normalizePickSet(referenceCase.pick)).toEqual(normalizePickSet(coreReservation.pick)); if (coreKernel.cancelled) { expect(coreReservation.tokenBefore).not.toBeNull(); expect(coreReservation.tokenAfter).toEqual(coreReservation.tokenBefore); } } const initial = firstReference.cases.find(({ name }) => name === 'initial-five'); expect(initial).toBeDefined(); expect(initial!.draws.length).toBeGreaterThan(initial!.pick.length); expect(firstReference.cases.find(({ name }) => name === 'all-keep-cancels-refresh')).toMatchObject({ cancelled: true, seed: null, randomDraws: [], draws: [], selectionStateUnchanged: true, }); expect( firstReference.cases.find(({ name }) => name === 'empty-existing-pick-cancels-refresh') ).toMatchObject({ cancelled: true, seed: null, randomDraws: [], draws: [], pick: [], selectionStateUnchanged: true, }); }, referenceComparisonTestTimeoutMs() ); });