import { afterAll, beforeAll, expect, it } from 'vitest'; import fastify from 'fastify'; import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify'; import { asRecord } from '@sammo-ts/common'; import { createGamePostgresConnector, createRedisConnector, GamePrisma } from '@sammo-ts/infra'; import { createTurnDaemonRuntime, seedScenarioToDatabase, type TurnDaemonRuntime } from '@sammo-ts/game-engine'; import { appRouter } from '../src/router.js'; import { DatabaseTurnDaemonTransport } from '../src/daemon/databaseTransport.js'; import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js'; import { InMemoryFlushStore } from '../src/auth/flushStore.js'; import type { GameApiContext } from '../src/context.js'; const databaseUrl = process.env.NPC_POLICY_DATABASE_URL; const integration = it.skipIf(!databaseUrl || !process.env.REDIS_URL); const connector = databaseUrl ? createGamePostgresConnector({ url: databaseUrl }) : null; const redis = process.env.REDIS_URL ? createRedisConnector({ url: process.env.REDIS_URL }) : null; let runtime: TurnDaemonRuntime | undefined; let loop: Promise | undefined; const profile = `npc-policy:${databaseUrl ? new URL(databaseUrl).searchParams.get('schema') : 'fixture'}`; const baseTime = new Date('0185-01-01T00:00:00Z'); beforeAll(async () => { if (!databaseUrl) return; if (!new URL(databaseUrl).searchParams.get('schema')?.endsWith('_npc_policy_fixture')) throw new Error('Dedicated NPC policy fixture schema required'); await connector!.connect(); await redis!.connect(); }); afterAll(async () => { if (loop) { await runtime?.lifecycle.stop(); await loop; } await runtime?.close(); await connector?.disconnect(); await redis?.disconnect(); }); integration( 'HTTP user edits survive daemon restart and real NPC turns, with readable actor and before/after history', async () => { const db = connector!.prisma; await db.playAuditPolicy.deleteMany({ where: { OR: [ { serverId: 'npc-policy-lifecycle' }, { id: { in: ['c'.repeat(64), 'd'.repeat(64), 'e'.repeat(64)] } }, ], }, }); await db.playAuditMonth.deleteMany({ where: { serverId: 'npc-policy-lifecycle' } }); await db.playAuditDecisionChunk.deleteMany({ where: { decision: { serverId: 'npc-policy-lifecycle' } } }); await db.playAuditDecision.deleteMany({ where: { serverId: 'npc-policy-lifecycle' } }); await db.playAuditDiplomacyEvent.deleteMany({ where: { serverId: 'npc-policy-lifecycle' } }); await db.inputEvent.deleteMany({ where: { requestId: { startsWith: 'npc-policy-http-' } } }); await seedScenarioToDatabase({ scenarioId: 903, databaseUrl: databaseUrl!, now: new Date(), installOptions: { openAt: new Date(Date.now() + 86_400_000), turnTermMinutes: 5, npcMode: 2, showImgLevel: 3, serverId: 'npc-policy-lifecycle', season: 1, }, }); await db.generalTurn.deleteMany(); await db.nationTurn.deleteMany(); await db.general.deleteMany(); await db.nation.deleteMany(); await db.diplomacy.deleteMany(); // 185년 고정 fixture에 180년 시나리오의 지연 등장 이벤트를 이월하지 않는다. // 일반턴·월간 재정/도시 갱신은 실제 runtime handler를 그대로 실행한다. await db.event.deleteMany(); const city = await db.city.findFirstOrThrow({ orderBy: { id: 'asc' } }); const world = await db.worldState.findFirstOrThrow(); await db.worldState.update({ where: { id: world.id }, data: { currentYear: 185, currentMonth: 1, clockBaseTime: baseTime, clockTick: 0n, lastTurnTick: 0n, clockPhase: 'RUNNING', clockMode: 'realtime', clockWallAnchor: new Date(), config: { ...asRecord(world.config), tournamentTrig: false }, }, }); await db.nation.create({ data: { id: 1, name: '정책국', color: '#ffffff', level: 3, chiefGeneralId: 1, capitalCityId: city.id, gold: 100_000, rice: 100_000, tech: 3000, meta: {}, }, }); await db.city.updateMany({ data: { nationId: 0 } }); await db.city.update({ where: { id: city.id }, data: { nationId: 1, population: 100_000, populationMax: 200_000, supplyState: 1, frontState: 1, meta: { trust: 80, trade: 100 }, }, }); await db.general.createMany({ data: [ { id: 1, userId: 'npc-policy-owner', name: '정책군주', nationId: 1, cityId: city.id, officerLevel: 12, npcState: 0, turnTime: new Date('0185-02-01Z'), turnTick: 100_000_000n, meta: { belong: 5, killturn: 24 }, }, { id: 2, name: '징병검증장', nationId: 1, cityId: city.id, officerLevel: 5, npcState: 2, leadership: 80, strength: 80, intel: 30, gold: 100_000, rice: 100_000, crew: 0, turnTime: baseTime, turnTick: 0n, meta: { killturn: 24, armType: 1, dex1: 1000 }, }, ], }); const transport = new DatabaseTurnDaemonTransport(db, 10_000); const auth: NonNullable = { version: 1, profile, sessionId: 'fixture-session', issuedAt: new Date().toISOString(), expiresAt: '2999-01-01T00:00:00Z', user: { id: 'npc-policy-owner', username: 'fixture', displayName: 'fixture', roles: [] }, sanctions: {}, }; const app = fastify(); let request = 0; await app.register(fastifyTRPCPlugin, { prefix: '/che/api/trpc', trpcOptions: { router: appRouter, createContext: ({ req, }: { req: { headers: Record }; }): GameApiContext => ({ db, redis: redis!.client, profile: { id: 'hwe', scenario: '903', name: profile }, auth: req.headers['x-session-token'] === 'fixture-session' ? auth : null, requestId: `npc-policy-http-${++request}`, turnDaemon: transport, accessTokenStore: new RedisAccessTokenStore(redis!.client, profile), flushStore: new InMemoryFlushStore(), battleSim: {} as GameApiContext['battleSim'], uploadDir: '/tmp', uploadPath: '/uploads', uploadPublicUrl: null, gameTokenSecret: 'fixture-only', }), }, }); const actions: Array<{ kind: string; actionKey: string; generalId: number; completed?: boolean }> = []; const start = async () => { const instance = await createTurnDaemonRuntime({ profile, databaseUrl: databaseUrl!, enableLeaseHeartbeat: false, onActionResolved: (action) => actions.push(action), }); // 자연 진행은 정지하고, 사용자 command와 명시한 due 턴만 처리한다. instance.lifecycle.pause('NPC policy fixture'); return instance; }; const call = async (endpoint: string, input: unknown, mutation = false) => { const response = await app.inject({ method: mutation ? 'POST' : 'GET', url: `/che/api/trpc/npc.${endpoint}${mutation ? '' : `?input=${encodeURIComponent(JSON.stringify(input))}`}`, headers: { 'x-session-token': 'fixture-session', ...(mutation ? { 'content-type': 'application/json' } : {}), }, ...(mutation ? { payload: JSON.stringify(input) } : {}), }); expect(response.statusCode, response.body).toBe(200); return response.json<{ result: { data: unknown } }>().result.data; }; const configuration = (value: unknown) => { const data = asRecord(value); return { values: data.currentNationPolicy, nation: data.currentNationPriority, general: data.currentGeneralActionPriority, setters: data.lastSetters, }; }; try { runtime = await start(); loop = runtime.lifecycle.start(); // 발령과 징병을 목록에서 빼면 실제 턴에서도 수행하지 않아야 한다. await call('setNationPriority', ['NPC포상'], true); await call('setGeneralPriority', ['출병', '일반내정'], true); await call( 'setNationPolicy', { minWarCrew: 2000, minNPCRecruitCityPopulation: 30000, safeRecruitCityPopulationRatio: 0 }, true ); const withoutRecruitment = await call('getPolicy', {}); expect(withoutRecruitment).toMatchObject({ currentNationPriority: ['NPC포상'], currentGeneralActionPriority: ['출병', '일반내정'], }); await runtime.lifecycle.stop(); await loop; loop = undefined; await runtime.close(); runtime = await start(); expect(configuration(await call('getPolicy', {}))).toEqual(configuration(withoutRecruitment)); let result = await runtime.processor.run(baseTime, { budgetMs: 10_000, maxGenerals: 1, catchUpCap: 1 }); expect(result.processedGenerals).toBe(1); expect(runtime.world.getGeneralById(2)!.crew).toBe(0); expect(actions).toContainEqual(expect.objectContaining({ generalId: 2, kind: 'nation' })); expect( actions.filter((action) => action.generalId === 2).some((action) => action.actionKey === 'che_징병') ).toBe(false); await runtime.hooks!.flushChanges!(result); const nationDecision = await db.playAuditDecision.findFirstOrThrow({ where: { generalId: 2, phase: 'nation' }, include: { chunks: { orderBy: { ordinal: 'asc' } } }, }); const procedures = nationDecision.chunks.flatMap((chunk) => Array.isArray(chunk.steps) ? chunk.steps .map(asRecord) .filter((step) => step.kind === 'PROCEDURE_START') .map((step) => step.procedure) : [] ); expect(procedures).toEqual(['NPC포상']); expect(configuration(await call('getPolicy', {}))).toEqual(configuration(withoutRecruitment)); // 같은 장수·자원으로 징병 우선순위를 맨 앞에 저장한 뒤 다시 시작한다. loop = runtime.lifecycle.start(); await call('setGeneralPriority', ['징병', '출병', '일반내정'], true); const saved = await call('getPolicy', {}); await runtime.lifecycle.stop(); await loop; loop = undefined; await runtime.close(); runtime = await start(); expect(configuration(await call('getPolicy', {}))).toEqual(configuration(saved)); const beforeMeta = asRecord(runtime.world.getNationById(1)!.meta); // 실제 due 턴과 command를 실행한다. 전방의 중립 도시가 전쟁 상태를 만든다. actions.length = 0; result = await runtime.processor.run(runtime.world.getGeneralById(2)!.turnTime, { budgetMs: 10_000, maxGenerals: 1, catchUpCap: 1, }); expect(result.processedGenerals).toBe(1); expect(runtime.world.getGeneralById(2)!.crew).toBeGreaterThan(0); expect(actions).toContainEqual( expect.objectContaining({ generalId: 2, actionKey: 'che_징병', completed: true }) ); await runtime.hooks!.flushChanges!(result); expect((await db.general.findUniqueOrThrow({ where: { id: 2 } })).crew).toBeGreaterThan(0); expect(asRecord(runtime.world.getNationById(1)!.meta).npc_nation_policy).toEqual( beforeMeta.npc_nation_policy ); expect(configuration(await call('getPolicy', {}))).toEqual(configuration(saved)); // 병력이 소진되고 도시 인구가 정책 하한 미만이면 징병을 거른다. runtime.world.updateGeneral(2, { crew: 0 }); runtime.world.updateCity(city.id, { population: 30_100 }); actions.length = 0; result = await runtime.processor.run(runtime.world.getGeneralById(2)!.turnTime, { budgetMs: 10_000, maxGenerals: 1, catchUpCap: 1, }); expect(result.processedGenerals).toBe(1); expect(runtime.world.getGeneralById(2)!.crew).toBe(0); expect(actions.some((action) => action.actionKey === 'che_징병')).toBe(false); await runtime.hooks!.flushChanges!(result); expect(configuration(await call('getPolicy', {}))).toEqual(configuration(saved)); // 이후의 개인턴/월간 갱신도 정책을 덮어쓰면 안 된다. const target = new Date(runtime.world.getGeneralById(2)!.turnTime.getTime() + 30 * 60_000); result = await runtime.processor.run(target, { budgetMs: 10_000, maxGenerals: 100, catchUpCap: 12 }); expect(result.processedGenerals).toBeGreaterThan(1); await runtime.hooks!.flushChanges!(result); expect(await call('getPolicy', {})).toMatchObject({ currentNationPriority: ['NPC포상'], currentGeneralActionPriority: ['징병', '출병', '일반내정'], currentNationPolicy: { minWarCrew: 2000, minNPCRecruitCityPopulation: 30000 }, }); expect(runtime.world.getState().meta.playAuditGap).toBeUndefined(); const history = await call('policyHistory', { area: 'NPC_NATION_PRIORITY' }); expect(history).toMatchObject({ items: [ expect.objectContaining({ source: 'CHANGE', actor: expect.objectContaining({ name: '정책군주' }) }), expect.anything(), ], }); const row = await db.playAuditPolicy.findFirstOrThrow({ where: { nationId: 1, area: 'NPC_NATION_PRIORITY', source: 'CHANGE' }, }); expect(await call('policyVersion', { id: row.id })).toMatchObject({ actor: { name: '정책군주' }, fields: [{ key: 'priority', beforeJson: 'null', afterJson: '["NPC포상"]', changed: true }], }); expect(row.inputSequence).not.toBeNull(); expect((await db.inputEvent.findUniqueOrThrow({ where: { sequence: row.inputSequence! } })).status).toBe( 'SUCCEEDED' ); // 외국/이전 기수/국방 정책 ID를 알아도 자국 NPC 정책으로 조회할 수 없다. for (const [id, patch] of [ ['c'.repeat(64), { nationId: 2 }], ['d'.repeat(64), { serverId: 'previous-season' }], ['e'.repeat(64), { area: 'DEFENCE' }], ] as const) { await db.playAuditPolicy.create({ data: { ...row, id, ...patch, actor: row.actor ?? GamePrisma.DbNull, before: row.before ?? GamePrisma.DbNull, after: row.after ?? GamePrisma.JsonNull, }, }); const response = await app.inject({ url: `/che/api/trpc/npc.policyVersion?input=${encodeURIComponent(JSON.stringify({ id }))}`, headers: { 'x-session-token': 'fixture-session' }, }); expect(response.statusCode).toBe(404); } await db.general.update({ where: { id: 1 }, data: { penalty: { noChief: true } } }); const denied = await app.inject({ url: `/che/api/trpc/npc.policyHistory?input=${encodeURIComponent(JSON.stringify({ area: 'NPC_VALUES' }))}`, headers: { 'x-session-token': 'fixture-session' }, }); expect(denied.statusCode).toBe(403); } finally { await app.close(); } }, 90_000 );