import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { RANK_DATA_TYPES } from '@sammo-ts/common'; import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; import { createTurnDaemonRuntime, seedScenarioToDatabase, type TurnDaemonRuntime } from '@sammo-ts/game-engine'; import { createGamePostgresConnector, type GamePrisma, type GamePrismaClient, type RedisConnector, } from '@sammo-ts/infra'; import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js'; import { InMemoryFlushStore } from '../src/auth/flushStore.js'; import { InMemoryBattleSimTransport } from '../src/battleSim/inMemoryTransport.js'; import type { GameApiContext } from '../src/context.js'; import { DatabaseTurnDaemonTransport } from '../src/daemon/databaseTransport.js'; import type { TurnDaemonTransport } from '../src/daemon/transport.js'; import { appRouter } from '../src/router.js'; const databaseUrl = process.env.SELECT_POOL_DATABASE_URL; const integration = describe.skipIf(!databaseUrl); const userId = 'select-pool-integration-user'; const otherUserId = 'select-pool-integration-other-user'; const foreignUserId = 'select-pool-integration-foreign-user'; const failureUserId = 'select-pool-integration-failure-user'; const profile = 'hwe:903'; const schemaName = databaseUrl ? (new URL(databaseUrl).searchParams.get('schema') ?? '') : ''; const assertDedicatedDatabase = (rawUrl: string): void => { const schema = new URL(rawUrl).searchParams.get('schema'); if (!schema?.endsWith('select_pool_integration')) { 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}`); } }; const auth: GameSessionTokenPayload = { version: 1, profile, issuedAt: '2026-07-30T00:00:00.000Z', expiresAt: '2026-08-30T00:00:00.000Z', sessionId: 'select-pool-integration-session', user: { id: userId, username: 'select-pool-user', displayName: '선택사용자', roles: ['user'], }, sanctions: {}, }; const otherAuth: GameSessionTokenPayload = { ...auth, sessionId: 'select-pool-integration-other-session', user: { ...auth.user, id: otherUserId, username: 'select-pool-other', displayName: '다른사용자', }, }; const foreignAuth: GameSessionTokenPayload = { ...auth, sessionId: 'select-pool-integration-foreign-session', user: { ...auth.user, id: foreignUserId, username: 'select-pool-foreign', displayName: '외국사용자', }, }; const failureAuth: GameSessionTokenPayload = { ...auth, sessionId: 'select-pool-integration-failure-session', user: { ...auth.user, id: failureUserId, username: 'select-pool-failure', displayName: '실패사용자', }, }; integration('scenario 903 select pool through the durable turn daemon', () => { let db: GamePrismaClient; let closeDb: (() => Promise) | undefined; let runtime: TurnDaemonRuntime | undefined; let daemonLoop: Promise | undefined; let turnDaemon: TurnDaemonTransport; let worldStateId: number; const buildContext = (requestId: string, actorAuth: GameSessionTokenPayload = auth): GameApiContext => { const redisClient = { get: async () => null, set: async () => null, }; return { requestId, db, redis: redisClient as unknown as RedisConnector['client'], turnDaemon, battleSim: new InMemoryBattleSimTransport(), profile: { id: 'hwe', scenario: '903', name: profile }, uploadDir: 'uploads', uploadPath: '/uploads', uploadPublicUrl: null, auth: actorAuth, accessTokenStore: new RedisAccessTokenStore(redisClient, profile), flushStore: new InMemoryFlushStore(), gameTokenSecret: 'select-pool-test-secret', }; }; beforeAll(async () => { assertDedicatedDatabase(databaseUrl!); const previousSeed = process.env.INTEGRATION_WORLD_SEED; process.env.INTEGRATION_WORLD_SEED = 'select-pool-integration-seed'; try { await seedScenarioToDatabase({ scenarioId: 903, databaseUrl: databaseUrl!, now: new Date('2099-07-30T12:00:00.000Z'), installOptions: { turnTermMinutes: 5, npcMode: 2, showImgLevel: 3, serverId: profile, 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.logEntry.deleteMany(); worldStateId = (await db.worldState.findFirstOrThrow()).id; runtime = await createTurnDaemonRuntime({ profile, databaseUrl: databaseUrl!, enableDatabaseFlush: true, enableLeaseHeartbeat: false, leaseOwnerId: 'select-pool-integration-daemon', }); turnDaemon = new DatabaseTurnDaemonTransport(db, 10_000); daemonLoop = runtime.lifecycle.start(); await expect(turnDaemon.requestStatus(10_000)).resolves.toMatchObject({ state: expect.any(String), }); }, 60_000); afterAll(async () => { if (runtime) { await runtime.lifecycle.stop('select-pool integration complete'); await daemonLoop; await runtime.close(); } await closeDb?.(); }, 30_000); it('creates and reselects in one durable DB and in-memory command boundary', async () => { await expect( appRouter.createCaller(buildContext('select-pool-public-lobby')).lobby.info() ).resolves.toMatchObject({ selectionPoolEnabled: true }); await expect( appRouter.createCaller(buildContext('select-pool-config')).join.getConfig() ).resolves.toMatchObject({ serverInfo: { currentYear: 180, currentMonth: 1, tickMinutes: 5, maxGeneral: 500, }, }); const [firstReservation, concurrentReservation] = await Promise.all([ appRouter.createCaller(buildContext('select-pool-reserve-a')).join.getSelectionPool(), appRouter.createCaller(buildContext('select-pool-reserve-b')).join.getSelectionPool(), ]); expect(concurrentReservation).toEqual(firstReservation); expect(firstReservation.candidates).toHaveLength(14); expect(await db.selectPoolEntry.count({ where: { ownerUserId: userId } })).toBe(14); const attempts = await Promise.allSettled([ appRouter.createCaller(buildContext('select-pool-create-a')).join.selectPoolGeneral({ uniqueName: firstReservation.candidates[0]!.uniqueName, personality: 'che_안전', }), appRouter.createCaller(buildContext('select-pool-create-b')).join.selectPoolGeneral({ uniqueName: firstReservation.candidates[1]!.uniqueName, personality: 'che_유지', }), ]); expect(attempts.filter((attempt) => attempt.status === 'fulfilled')).toHaveLength(1); expect(attempts.filter((attempt) => attempt.status === 'rejected')).toHaveLength(1); const initial = await db.general.findFirstOrThrow({ where: { userId } }); const initialRuntime = runtime!.world.getGeneralById(initial.id); const initialAccess = await db.generalAccessLog.findUniqueOrThrow({ where: { generalId: initial.id } }); if (!initialAccess.lastRefresh) { throw new Error('selected general must have an initial access timestamp'); } expect( new Date((initial.meta as Record).prestart_delete_after as string).getTime() - initialAccess.lastRefresh.getTime() ).toBe(2 * 5 * 60 * 1_000); expect(initialRuntime).toMatchObject({ id: initial.id, userId, name: initial.name, imageServer: initial.imageServer, stats: { leadership: initial.leadership, strength: initial.strength, intelligence: initial.intel, }, }); expect(initial.id).toBeGreaterThan( Math.max( ...runtime!.world .listGenerals() .filter((general) => general.id !== initial.id) .map((general) => general.id) ) ); expect( await db.generalTurn.findMany({ where: { generalId: initial.id }, orderBy: { turnIdx: 'asc' }, select: { turnIdx: true, actionCode: true, arg: true }, }) ).toEqual( Array.from({ length: 30 }, (_, turnIdx) => ({ turnIdx, actionCode: '휴식', arg: {}, })) ); await expect( db.generalTurnRevision.findUniqueOrThrow({ where: { generalId: initial.id } }) ).resolves.toMatchObject({ revision: 0, leaseOwner: null, leaseExpiresAt: null }); const initialRankRows = await db.rankData.findMany({ where: { generalId: initial.id }, orderBy: { type: 'asc' }, select: { nationId: true, type: true, value: true }, }); expect(initialRankRows).toHaveLength(RANK_DATA_TYPES.length); expect(initialRankRows.map(({ type }) => type).sort()).toEqual([...RANK_DATA_TYPES].sort()); expect(initialRankRows.every(({ nationId, value }) => nationId === 0 && value === 0)).toBe(true); expect(await db.selectPoolEntry.count({ where: { generalId: initial.id } })).toBe(1); expect(await db.selectPoolEntry.count({ where: { ownerUserId: userId } })).toBe(0); expect( await db.logEntry.count({ where: { meta: { path: ['ownerUserId'], equals: userId } }, }) ).toBe(2); await expect( appRouter.createCaller(buildContext('select-pool-cooldown')).join.getSelectionPool() ).rejects.toMatchObject({ message: '아직 다시 고를 수 없습니다' }); const cooledAt = '2026-07-29T00:00:00.000Z'; await expect( turnDaemon.requestCommand({ type: 'patchGeneral', requestId: 'select-pool-cooldown-patch', generalId: initial.id, patch: { meta: { next_change: cooledAt, nextChangeAt: cooledAt, }, }, }) ).resolves.toMatchObject({ type: 'patchGeneral', ok: true, generalId: initial.id }); const reselection = await appRouter .createCaller(buildContext('select-pool-reserve-reselection')) .join.getSelectionPool(); const target = reselection.candidates.find((candidate) => candidate.generalName !== initial.name)!; await expect( appRouter .createCaller(buildContext('select-pool-reselect')) .join.reselectPoolGeneral({ uniqueName: target.uniqueName }) ).resolves.toEqual({ ok: true, generalId: initial.id }); const updated = await db.general.findUniqueOrThrow({ where: { id: initial.id } }); expect(updated).toMatchObject({ id: initial.id, userId, name: target.generalName, leadership: target.leadership, strength: target.strength, intel: target.intel, personalCode: initial.personalCode, specialCode: target.specialDomestic, imageServer: target.imageServer, picture: target.picture, }); expect(runtime!.world.getGeneralById(initial.id)).toMatchObject({ id: initial.id, userId, name: target.generalName, imageServer: target.imageServer, picture: target.picture, stats: { leadership: target.leadership, strength: target.strength, intelligence: target.intel, }, }); expect(await db.selectPoolEntry.count({ where: { generalId: initial.id } })).toBe(1); expect(await db.selectPoolEntry.findUniqueOrThrow({ where: { uniqueName: target.uniqueName } })).toMatchObject({ generalId: initial.id, ownerUserId: null, reservedUntil: null, }); expect( await db.logEntry.count({ where: { meta: { path: ['ownerUserId'], equals: userId } }, }) ).toBe(4); await expect( turnDaemon.requestCommand({ type: 'patchGeneral', requestId: 'select-pool-post-reselection-flush', generalId: initial.id, patch: { meta: { postReselectionFlush: 1 } }, }) ).resolves.toMatchObject({ type: 'patchGeneral', ok: true }); await expect(db.general.findUniqueOrThrow({ where: { id: initial.id } })).resolves.toMatchObject({ name: target.generalName, leadership: target.leadership, strength: target.strength, intel: target.intel, }); const fullWorld = await db.worldState.findUniqueOrThrow({ where: { id: worldStateId } }); const fullConfig = fullWorld.config as Record; await db.worldState.update({ where: { id: worldStateId }, data: { config: { ...fullConfig, maxGeneral: 1 } as GamePrisma.InputJsonValue }, }); const secondCooledAt = '2026-07-28T00:00:00.000Z'; await turnDaemon.requestCommand({ type: 'patchGeneral', requestId: 'select-pool-full-cooldown-patch', generalId: initial.id, patch: { meta: { next_change: secondCooledAt, nextChangeAt: secondCooledAt, }, }, }); const fullReselection = await appRouter .createCaller(buildContext('select-pool-full-reselection-reserve')) .join.getSelectionPool(); await expect( appRouter.createCaller(buildContext('select-pool-full-reselection')).join.reselectPoolGeneral({ uniqueName: fullReselection.candidates[0]!.uniqueName, }) ).resolves.toEqual({ ok: true, generalId: initial.id }); const otherReservation = await appRouter .createCaller(buildContext('select-pool-full-new-user-reserve', otherAuth)) .join.getSelectionPool(); await expect( appRouter.createCaller(buildContext('select-pool-full-new-user-create', otherAuth)).join.selectPoolGeneral({ uniqueName: otherReservation.candidates[0]!.uniqueName, personality: 'che_안전', }) ).rejects.toMatchObject({ message: '더 이상 등록 할 수 없습니다.' }); expect(await db.general.count({ where: { userId: otherUserId } })).toBe(0); await db.worldState.update({ where: { id: worldStateId }, data: { config: fullConfig as GamePrisma.InputJsonValue }, }); }, 30_000); it('keeps a stable ENGINE event for retries and rejects reservation bypasses', async () => { const reservation = await appRouter .createCaller(buildContext('select-pool-other-reserve', otherAuth)) .join.getSelectionPool(); const candidate = reservation.candidates[0]!; await expect( appRouter.createCaller(buildContext('select-pool-foreign-token', foreignAuth)).join.selectPoolGeneral({ uniqueName: candidate.uniqueName, personality: 'che_안전', }) ).rejects.toMatchObject({ message: '유효한 장수 목록이 없습니다.' }); expect(await db.general.count({ where: { userId: foreignUserId } })).toBe(0); await db.selectPoolEntry.update({ where: { uniqueName: candidate.uniqueName }, data: { reservedUntil: new Date(Date.now() - 60_000) }, }); await expect( appRouter.createCaller(buildContext('select-pool-expired-token', otherAuth)).join.selectPoolGeneral({ uniqueName: candidate.uniqueName, personality: 'che_안전', }) ).rejects.toMatchObject({ message: '유효한 장수 목록이 없습니다.' }); expect(await db.general.count({ where: { userId: otherUserId } })).toBe(0); await expect( appRouter.createCaller(buildContext('select-pool-generic-bypass', otherAuth)).join.createGeneral({ name: '우회장수', leadership: 55, strength: 55, intel: 55, pic: false, character: 'che_안전', }) ).rejects.toMatchObject({ message: '장수 선택 목록에서 장수를 골라 주세요.' }); const input = { uniqueName: reservation.candidates[1]!.uniqueName, personality: 'che_안전', }; await db.selectPoolEntry.updateMany({ where: { ownerUserId: otherUserId, generalId: null }, data: { reservedUntil: new Date(Date.now() + 60_000) }, }); const runtimeAllocatorBefore = runtime!.world.getState().meta.lastGeneralId; const persistedAllocatorBefore = ( (await db.worldState.findUniqueOrThrow({ where: { id: worldStateId } })).meta as Record ).lastGeneralId; await expect( appRouter.createCaller(buildContext('select-pool-invalid-personality', otherAuth)).join.selectPoolGeneral({ ...input, personality: 'not-a-personality', clientRequestId: '11111111-1111-4111-8111-111111111111', }) ).rejects.toMatchObject({ message: '올바르지 않은 성격입니다.' }); expect(runtime!.world.getState().meta.lastGeneralId).toBe(runtimeAllocatorBefore); expect( ((await db.worldState.findUniqueOrThrow({ where: { id: worldStateId } })).meta as Record) .lastGeneralId ).toBe(persistedAllocatorBefore); expect(await db.general.count({ where: { userId: otherUserId } })).toBe(0); const stableClientRequestId = '22222222-2222-4222-8222-222222222222'; const stableInput = { ...input, clientRequestId: stableClientRequestId }; const first = await appRouter .createCaller(buildContext('select-pool-http-attempt-a', otherAuth)) .join.selectPoolGeneral(stableInput); const retried = await appRouter .createCaller(buildContext('select-pool-http-attempt-b', otherAuth)) .join.selectPoolGeneral(stableInput); expect(retried).toEqual(first); expect(await db.general.count({ where: { userId: otherUserId } })).toBe(1); await expect( db.inputEvent.findUniqueOrThrow({ where: { requestId: `select-pool:${otherUserId}:${stableClientRequestId}:create`, }, }) ).resolves.toMatchObject({ status: 'SUCCEEDED', attempts: 1, actorUserId: otherUserId, }); }, 30_000); it('rolls back a hard failure and retries the same ENGINE event exactly once', async () => { const reservation = await appRouter .createCaller(buildContext('select-pool-failure-reserve', failureAuth)) .join.getSelectionPool(); const candidate = reservation.candidates[0]!; const requestUuid = '33333333-3333-4333-8333-333333333333'; const requestId = `select-pool:${failureUserId}:${requestUuid}:create`; const triggerName = 'select_pool_fail_first_log'; const functionName = 'select_pool_fail_first_log_fn'; await db.$executeRawUnsafe(` CREATE OR REPLACE FUNCTION "${schemaName}"."${functionName}"() RETURNS trigger AS $$ BEGIN IF NEW.meta ->> 'ownerUserId' = '${failureUserId}' AND EXISTS ( SELECT 1 FROM "${schemaName}"."input_event" WHERE "request_id" = '${requestId}' AND "status" = 'PROCESSING' AND "attempts" = 1 ) THEN RAISE EXCEPTION 'injected first selection log failure'; END IF; RETURN NEW; END; $$ LANGUAGE plpgsql `); await db.$executeRawUnsafe(` CREATE TRIGGER "${triggerName}" BEFORE INSERT ON "${schemaName}"."log_entry" FOR EACH ROW EXECUTE FUNCTION "${schemaName}"."${functionName}"() `); try { await expect( appRouter.createCaller(buildContext('select-pool-failure-http', failureAuth)).join.selectPoolGeneral({ uniqueName: candidate.uniqueName, personality: 'che_안전', clientRequestId: requestUuid, }) ).resolves.toMatchObject({ ok: true, generalId: expect.any(Number) }); } finally { await db.$executeRawUnsafe(`DROP TRIGGER IF EXISTS "${triggerName}" ON "${schemaName}"."log_entry"`); await db.$executeRawUnsafe(`DROP FUNCTION IF EXISTS "${schemaName}"."${functionName}"()`); } const created = await db.general.findFirstOrThrow({ where: { userId: failureUserId } }); expect(runtime!.world.getGeneralById(created.id)).toMatchObject({ id: created.id, userId: failureUserId, name: created.name, }); expect(await db.general.count({ where: { userId: failureUserId } })).toBe(1); expect(await db.generalTurn.count({ where: { generalId: created.id } })).toBe(30); expect(await db.generalTurnRevision.count({ where: { generalId: created.id } })).toBe(1); expect(await db.rankData.count({ where: { generalId: created.id } })).toBe(RANK_DATA_TYPES.length); expect(await db.generalAccessLog.count({ where: { generalId: created.id } })).toBe(1); expect(await db.selectPoolEntry.count({ where: { generalId: created.id } })).toBe(1); expect( await db.logEntry.count({ where: { meta: { path: ['ownerUserId'], equals: failureUserId } }, }) ).toBe(2); await expect(db.inputEvent.findUniqueOrThrow({ where: { requestId } })).resolves.toMatchObject({ status: 'SUCCEEDED', attempts: 2, actorUserId: failureUserId, error: null, }); }, 30_000); });