import { afterEach, describe, expect, it } from 'vitest'; import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; import { RedisAccessTokenStore } from '@sammo-ts/game-api/auth/accessTokenStore.js'; import { InMemoryFlushStore } from '@sammo-ts/game-api/auth/flushStore.js'; import { InMemoryBattleSimTransport } from '@sammo-ts/game-api/battleSim/inMemoryTransport.js'; import type { GameApiContext } from '@sammo-ts/game-api/context.js'; import { InMemoryTurnDaemonTransport } from '@sammo-ts/game-api/daemon/inMemoryTransport.js'; import { appRouter } from '@sammo-ts/game-api/router.js'; import { createTurnDaemonRuntime, seedScenarioToDatabase } from '@sammo-ts/game-engine'; import { createFinishNationBettingHandler } from '@sammo-ts/game-engine/turn/monthlyNationBettingAction.js'; import { createMonthlyEventHandler } from '@sammo-ts/game-engine/turn/monthlyEventHandler.js'; import { createGamePostgresConnector, type RedisConnector, } from '@sammo-ts/infra'; const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL; const integration = describe.skipIf(!databaseUrl); const scenarioId = 2900; const userIds = ['scenario29-betting-winner', 'scenario29-betting-loser'] as const; const openedMessage = '새로운 천통국 내기가 열렸습니다. 천통국 베팅란을 확인해주세요.'; type TurnDaemonRuntime = Awaited>; const buildAuth = (index: 0 | 1): GameSessionTokenPayload => ({ version: 1, profile: `che:${scenarioId}`, issuedAt: '2026-08-15T00:00:00.000Z', expiresAt: '2026-08-16T00:00:00.000Z', sessionId: `scenario29-betting-session-${index + 1}`, user: { id: userIds[index], username: `scenario29-bettor-${index + 1}`, displayName: `시나리오29 베팅유저 ${index + 1}`, roles: ['user'], }, sanctions: {}, }); const readMessageText = (value: unknown): string | null => { if (!value || typeof value !== 'object' || Array.isArray(value) || !('text' in value)) { return null; } return typeof value.text === 'string' ? value.text : null; }; integration('scenario 29 nation betting lifecycle', () => { let runtime: TurnDaemonRuntime | null = null; let closeDb: (() => Promise) | null = null; afterEach(async () => { await runtime?.close(); runtime = null; await closeDb?.(); closeDb = null; }); it('starts scenario 2900, announces and accepts nation betting, then settles the winning user', async () => { let generalIds: [number, number] | null = null; await seedScenarioToDatabase({ scenarioId, databaseUrl: databaseUrl!, now: new Date('2026-08-15T00:00:00.000Z'), gameClockMode: 'manual', installOptions: { turnTermMinutes: 120, sync: false, tournamentTrig: false, }, onBeforeCommit: async (prisma) => { const generals = await prisma.general.findMany({ where: { nationId: { gt: 0 } }, orderBy: { id: 'asc' }, take: 2, select: { id: true }, }); if (generals.length !== 2 || !generals[0] || !generals[1]) { throw new Error('Scenario 2900 must seed at least two bettable generals.'); } generalIds = [generals[0].id, generals[1].id]; await Promise.all( generalIds.map((generalId, index) => prisma.general.update({ where: { id: generalId }, data: { userId: userIds[index], npcState: 0 }, }) ) ); await Promise.all( userIds.map((userId) => prisma.inheritancePoint.upsert({ where: { userId_key: { userId, key: 'previous' } }, create: { userId, key: 'previous', value: 1_000 }, update: { value: 1_000 }, }) ) ); }, }); if (!generalIds) { throw new Error('Scenario 2900 bettor fixtures were not created.'); } const connector = createGamePostgresConnector({ url: databaseUrl! }); await connector.connect(); const db = connector.prisma; closeDb = () => connector.disconnect(); runtime = await createTurnDaemonRuntime({ profile: 'che', databaseUrl: databaseUrl!, gameClockMode: 'manual', enableLeaseHeartbeat: false, leaseDurationMs: 300_000, exclusiveFastForward: true, }); const buildContext = (index: 0 | 1): GameApiContext => { const redisClient = { get: async () => null, set: async () => null, }; return { requestId: `scenario29-betting-${index + 1}`, db, redis: redisClient as unknown as RedisConnector['client'], turnDaemon: new InMemoryTurnDaemonTransport(), battleSim: new InMemoryBattleSimTransport(), profile: { id: 'che', scenario: String(scenarioId), name: `che:${scenarioId}` }, uploadDir: 'uploads', uploadPath: '/uploads', uploadPublicUrl: null, auth: buildAuth(index), accessTokenStore: new RedisAccessTokenStore(redisClient, `che:${scenarioId}`), flushStore: new InMemoryFlushStore(), gameTokenSecret: 'scenario29-betting-test-secret', }; }; expect(runtime.world.getState()).toMatchObject({ currentYear: 2025, currentMonth: 1 }); expect(runtime.world.listEvents('month').map((event) => event.action)).toContainEqual([ ['OpenNationBetting', 1, 2000], ['DeleteEvent'], ]); for (let month = 0; month < 12; month += 1) { const state = runtime.world.getState(); const turnTime = new Date(state.lastTurnTime.getTime() + state.tickSeconds * 1_000); await runtime.world.advanceMonth(turnTime); await runtime.hooks?.flushChanges?.({ lastTurnTime: runtime.world.getState().lastTurnTime.toISOString(), processedGenerals: 0, processedTurns: 1, durationMs: 0, partial: false, }); } expect(runtime.world.getState()).toMatchObject({ currentYear: 2026, currentMonth: 1 }); const betting = await db.nationBetting.findFirstOrThrow({ where: { name: '천통국 예상', finished: false }, orderBy: { id: 'desc' }, }); expect(betting).toMatchObject({ selectCount: 1, openYearMonth: 2026 * 12, closeYearMonth: 2028 * 12 }); expect( await db.nationBet.findFirstOrThrow({ where: { bettingId: betting.id, generalId: 0 }, select: { selectionKey: true, amount: true }, }) ).toEqual({ selectionKey: '[-1]', amount: 2000 }); const historyLogs = await db.logEntry.findMany({ where: { text: { contains: '천하통일 후보를 점치는' } }, orderBy: { id: 'asc' }, select: { text: true }, }); expect(historyLogs.map((entry) => entry.text)).toContain( '●2026년 1월:【내기】천하통일 후보를 점치는 내기가 진행중입니다! 호사가의 참여를 기다립니다!' ); const messages = await db.message.findMany({ where: { mailbox: { in: generalIds } }, orderBy: { id: 'asc' }, select: { mailbox: true, message: true }, }); expect( messages .filter((message) => readMessageText(message.message) === openedMessage) .map((message) => message.mailbox) .sort((left, right) => left - right) ).toEqual([...generalIds].sort((left, right) => left - right)); const candidates = Array.isArray(betting.candidates) ? betting.candidates : []; const candidateNationIds = candidates.flatMap((candidate) => { if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate) || !('aux' in candidate)) { return []; } const aux = candidate.aux; if (!aux || typeof aux !== 'object' || Array.isArray(aux) || !('nation' in aux)) { return []; } return typeof aux.nation === 'number' ? [aux.nation] : []; }); expect(candidateNationIds.length).toBeGreaterThan(1); const winnerNationId = candidateNationIds[0]!; const loserSelection = 1; await expect( appRouter.createCaller(buildContext(0)).betting.bet({ bettingId: betting.id, bettingType: [0], amount: 100, }) ).resolves.toEqual({ result: true }); await expect( appRouter.createCaller(buildContext(1)).betting.bet({ bettingId: betting.id, bettingType: [loserSelection], amount: 100, }) ).resolves.toEqual({ result: true }); expect( await Promise.all( userIds.map(async (userId) => ( await db.inheritancePoint.findUniqueOrThrow({ where: { userId_key: { userId, key: 'previous' } }, }) ).value ) ) ).toEqual([900, 900]); for (const nation of runtime.world.listNations()) { if (nation.id > 0 && nation.id !== winnerNationId) { runtime.world.removeNation(nation.id); } } const finishHandler = createFinishNationBettingHandler({ getWorld: () => runtime?.world ?? null }); const eventHandler = createMonthlyEventHandler({ getWorld: () => runtime?.world ?? null, startYear: 2025, actions: new Map([['FinishNationBetting', finishHandler]]), }); const finishState = runtime.world.getState(); await eventHandler.dispatchTarget('destroy_nation', { previousYear: finishState.currentYear, previousMonth: finishState.currentMonth, currentYear: finishState.currentYear, currentMonth: finishState.currentMonth, turnTime: new Date(finishState.lastTurnTime.getTime() + finishState.tickSeconds * 1_000), }); await runtime.hooks?.flushChanges?.({ lastTurnTime: runtime.world.getState().lastTurnTime.toISOString(), processedGenerals: 0, processedTurns: 0, durationMs: 0, partial: false, }); expect(await db.nationBetting.findUniqueOrThrow({ where: { id: betting.id } })).toMatchObject({ finished: true, winner: [0], }); expect( await Promise.all( userIds.map(async (userId) => ( await db.inheritancePoint.findUniqueOrThrow({ where: { userId_key: { userId, key: 'previous' } }, }) ).value ) ) ).toEqual([3_100, 900]); expect( await db.rankData.findUniqueOrThrow({ where: { generalId_type: { generalId: generalIds[0], type: 'inherit_earned_act' } }, select: { value: true }, }) ).toEqual({ value: 2_200 }); expect( await db.logEntry.findFirst({ where: { text: { contains: '천통국 예상 내기의 결과가 나왔습니다' } }, select: { text: true }, }) ).not.toBeNull(); }, 120_000); });