import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { SystemClock } from '@sammo-ts/common'; import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; import { createDatabaseTurnHooks, DatabaseTurnDaemonCommandQueue, EngineStateManager, InMemoryTurnStateStore, InMemoryTurnWorld, loadTurnWorldFromDatabase, TurnDaemonLifecycle, type TurnGeneral, type TurnWorldSnapshot, type TurnWorldState, } from '@sammo-ts/game-engine'; import { createTurnDaemonCommandHandler } from '@sammo-ts/game-engine/turn/worldCommandHandler.js'; import { createGamePostgresConnector, type GamePrisma, type GamePrismaClient, type RedisConnector, } from '@sammo-ts/infra'; import type { MapDefinition, ScenarioConfig, ScenarioMeta, TurnSchedule } from '@sammo-ts/logic'; import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js'; import { InMemoryBattleSimTransport } from '../src/battleSim/inMemoryTransport.js'; import type { GameApiContext } from '../src/context.js'; import { DatabaseTurnDaemonTransport } from '../src/daemon/databaseTransport.js'; import { InMemoryFlushStore } from '../src/auth/flushStore.js'; import { appRouter } from '../src/router.js'; const databaseUrl = process.env.IMMEDIATE_ACTION_DATABASE_URL; const integration = describe.skipIf(!databaseUrl); const worldId = 992_320; const actorId = 7_320; const targetId = 7_321; const actorNationId = 7_322; const targetNationId = 7_323; const actorUserId = 'inherit-owner-message-actor'; const targetUserId = 'inherit-owner-message-target'; const requestId = 'integration:inherit-owner-message:success'; const engineRequestId = `${requestId}:inherit.checkOwner:engine:0:inheritanceAction`; const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 10 }] }; const scenarioConfig: ScenarioConfig = { stat: { total: 200, min: 10, max: 100, npcTotal: 150, npcMax: 75, npcMin: 10, chiefMin: 70 }, iconPath: '.', map: {}, const: { inheritCheckOwnerPoint: 1_000 }, environment: { mapName: 'che', unitSet: 'che' }, }; const scenarioMeta: ScenarioMeta = { title: '소유자 확인 통합', startYear: 200, life: null, fiction: null, history: [], ignoreDefaultEvents: false, }; const map: MapDefinition = { id: 'inherit-owner-message', name: scenarioMeta.title, cities: [] }; const state: TurnWorldState = { id: worldId, currentYear: 200, currentMonth: 4, tickSeconds: 600, lastTurnTime: new Date('2026-08-19T00:00:00.000Z'), meta: { hiddenSeed: 'inherit-owner-message', isunited: 0, scenarioMeta }, }; const general = (overrides: Partial): TurnGeneral => ({ id: actorId, userId: actorUserId, name: '확인장수', nationId: actorNationId, cityId: 1, troopId: 0, stats: { leadership: 70, strength: 45, intelligence: 85 }, turnTime: new Date('2026-08-19T00:10:00.000Z'), recentWarTime: null, role: { items: { horse: null, weapon: null, book: null, item: null }, personality: null, specialDomestic: null, specialWar: null, }, triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} }, meta: { killturn: 24, inherit_spent_dyn: 0 }, inheritancePoints: { previous: 1_500 }, penalty: {}, officerLevel: 1, experience: 0, dedication: 0, injury: 0, gold: 1_000, rice: 1_000, crew: 0, crewTypeId: 0, train: 0, atmos: 0, age: 30, npcState: 0, ...overrides, }); const actor = general({}); const target = general({ id: targetId, userId: targetUserId, name: '피확인장수', nationId: targetNationId, meta: { killturn: 24, owner_name: '피확인 계정' }, inheritancePoints: { previous: 0 }, }); const nation = (id: number, name: string, chiefGeneralId: number) => ({ id, name, color: id === actorNationId ? '#123456' : '#654321', capitalCityId: null, chiefGeneralId, gold: 0, rice: 0, power: 0, level: 1, typeCode: 'che_def', meta: {}, }); const actorNation = nation(actorNationId, '확인국', actorId); const targetNation = nation(targetNationId, '피확인국', targetId); const auth: GameSessionTokenPayload = { version: 1, profile: 'che:inherit-owner-message', issuedAt: '2026-08-19T00:00:00.000Z', expiresAt: '2026-08-20T00:00:00.000Z', sessionId: 'inherit-owner-message-session', user: { id: actorUserId, username: actorUserId, displayName: '확인자 계정', roles: ['user'] }, sanctions: {}, }; const toCreate = (entry: TurnGeneral): GamePrisma.GeneralCreateManyInput => ({ id: entry.id, userId: entry.userId, name: entry.name, nationId: entry.nationId, cityId: entry.cityId, npcState: entry.npcState, leadership: entry.stats.leadership, strength: entry.stats.strength, intel: entry.stats.intelligence, turnTime: entry.turnTime, meta: entry.meta as GamePrisma.InputJsonValue, penalty: entry.penalty as GamePrisma.InputJsonValue, }); integration('inherit owner lookup private messages', () => { let db: GamePrismaClient; let closeDb: (() => Promise) | undefined; beforeAll(async () => { const schema = new URL(databaseUrl!).searchParams.get('schema'); if (!schema?.endsWith('immediate_action_integration')) throw new Error(`Unsafe schema: ${schema}`); const connector = createGamePostgresConnector({ url: databaseUrl! }); await connector.connect(); db = connector.prisma; closeDb = () => connector.disconnect(); await db.inputEvent.deleteMany({ where: { requestId: engineRequestId } }); await db.message.deleteMany({ where: { mailbox: { in: [actorId, targetId] } } }); await db.inheritanceLog.deleteMany({ where: { userId: actorUserId } }); await db.inheritancePoint.deleteMany({ where: { userId: { in: [actorUserId, targetUserId] } } }); await db.rankData.deleteMany({ where: { generalId: { in: [actorId, targetId] } } }); await db.general.deleteMany({ where: { id: { in: [actorId, targetId] } } }); await db.nation.deleteMany({ where: { id: { in: [actorNationId, targetNationId] } } }); await db.worldState.deleteMany({ where: { id: worldId } }); await db.worldState.create({ data: { id: worldId, scenarioCode: 'inherit-owner-message', currentYear: 200, currentMonth: 4, tickSeconds: 600, config: JSON.parse(JSON.stringify(scenarioConfig)) as GamePrisma.InputJsonValue, meta: state.meta as GamePrisma.InputJsonValue, }, }); await db.nation.createMany({ data: [ { id: actorNationId, name: actorNation.name, color: actorNation.color, level: 1 }, { id: targetNationId, name: targetNation.name, color: targetNation.color, level: 1 }, ], }); await db.general.createMany({ data: [actor, target].map(toCreate) }); await db.inheritancePoint.create({ data: { userId: actorUserId, key: 'previous', value: 1_500 } }); await db.rankData.create({ data: { generalId: actorId, nationId: actorNationId, type: 'inherit_spent_dyn', value: 0 }, }); }); afterAll(async () => { if (db) { await db.inputEvent.deleteMany({ where: { requestId: engineRequestId } }); await db.message.deleteMany({ where: { mailbox: { in: [actorId, targetId] } } }); await db.inheritanceLog.deleteMany({ where: { userId: actorUserId } }); await db.inheritancePoint.deleteMany({ where: { userId: { in: [actorUserId, targetUserId] } } }); await db.rankData.deleteMany({ where: { generalId: { in: [actorId, targetId] } } }); await db.general.deleteMany({ where: { id: { in: [actorId, targetId] } } }); await db.nation.deleteMany({ where: { id: { in: [actorNationId, targetNationId] } } }); await db.worldState.deleteMany({ where: { id: worldId } }); } await closeDb?.(); }); it('commits once and returns the durable result without double charging on the same API retry', async () => { const snapshot: TurnWorldSnapshot = { generals: [actor, target], cities: [], nations: [actorNation, targetNation], troops: [], diplomacy: [], events: [], initialEvents: [], scenarioConfig, scenarioMeta, map, }; const world = new InMemoryTurnWorld(state, snapshot, { schedule }); const queue = new DatabaseTurnDaemonCommandQueue(db); await queue.initialize(); const hooks = await createDatabaseTurnHooks(databaseUrl!, world); const stateManager = new EngineStateManager(); stateManager.register('world', { capture: () => world.captureState(), restore: (value) => world.restoreState(value), }); const lifecycle = new TurnDaemonLifecycle( { clock: new SystemClock(), controlQueue: queue, commandResponder: queue, getNextTickTime: () => new Date(Date.now() + 3_600_000), stateStore: new InMemoryTurnStateStore(world), processor: { run: async () => { throw new Error('scheduled turn must not run in inheritance API integration'); }, }, commandHandler: createTurnDaemonCommandHandler({ world }), hooks: hooks.hooks, stateManager, }, { profile: 'inherit-owner-message', defaultBudget: { budgetMs: 100, maxGenerals: 1, catchUpCap: 1 } } ); const redisClient = { get: async () => null, set: async () => null }; const context: GameApiContext = { requestId, db, redis: redisClient as unknown as RedisConnector['client'], turnDaemon: new DatabaseTurnDaemonTransport(db, 10_000), battleSim: new InMemoryBattleSimTransport(), profile: { id: 'che', scenario: 'inherit-owner-message', name: 'che:inherit-owner-message' }, uploadDir: 'uploads', uploadPath: '/uploads', uploadPublicUrl: null, auth, accessTokenStore: new RedisAccessTokenStore(redisClient, 'che:inherit-owner-message'), flushStore: new InMemoryFlushStore(), gameTokenSecret: 'test-secret', }; let loop: Promise | undefined; try { loop = lifecycle.start(); const caller = appRouter.createCaller(context); const expected = { ok: true, ownerName: '피확인 계정', targetName: '피확인장수' }; await expect(caller.inherit.checkOwner({ targetGeneralId: targetId })).resolves.toEqual(expected); await expect(caller.inherit.checkOwner({ targetGeneralId: targetId })).resolves.toEqual(expected); } finally { await lifecycle.stop('inherit owner integration finished'); await loop; await hooks.close(); } await expect( db.inheritancePoint.findUniqueOrThrow({ where: { userId_key: { userId: actorUserId, key: 'previous' } } }) ).resolves.toMatchObject({ value: 500 }); await expect( db.rankData.findUniqueOrThrow({ where: { generalId_type: { generalId: actorId, type: 'inherit_spent_dyn' } }, }) ).resolves.toMatchObject({ value: 1_000 }); await expect(db.inheritanceLog.count({ where: { userId: actorUserId } })).resolves.toBe(1); const messages = await db.message.findMany({ where: { mailbox: { in: [actorId, targetId] } }, orderBy: { id: 'asc' }, select: { mailbox: true, message: true }, }); expect(messages.map(({ mailbox, message }) => [mailbox, (message as { text: string }).text])).toEqual([ [actorId, '피확인장수의 소유자는 피확인 계정 입니다.'], [targetId, '소유자명이 누군가에 의해 확인되었습니다.'], ]); await expect(db.inputEvent.findMany({ where: { requestId: engineRequestId } })).resolves.toEqual([ expect.objectContaining({ status: 'SUCCEEDED', attempts: 1, actorUserId }), ]); const reloaded = await loadTurnWorldFromDatabase({ databaseUrl: databaseUrl! }); expect(reloaded.snapshot.generals.find((entry) => entry.id === actorId)).toMatchObject({ meta: { inherit_spent_dyn: 1_000 }, inheritancePoints: { previous: 500 }, }); }, 30_000); });