From b92774bc45ca94c07401b3888da6f7e0c00fd716 Mon Sep 17 00:00:00 2001 From: hided62 Date: Sun, 26 Jul 2026 05:09:31 +0000 Subject: [PATCH] fix tournament lifecycle settlement --- app/game-api/src/index.ts | 3 + app/game-api/src/tournament/worker.ts | 154 +++--- app/game-api/src/tournament/workerHelpers.ts | 5 +- app/game-api/test/tournamentWorker.test.ts | 114 ++++- .../test/tournamentLifecycle.test.ts | 468 ++++++++++++++++++ 5 files changed, 683 insertions(+), 61 deletions(-) create mode 100644 tools/integration-tests/test/tournamentLifecycle.test.ts diff --git a/app/game-api/src/index.ts b/app/game-api/src/index.ts index c69c4e6..4de2b2d 100644 --- a/app/game-api/src/index.ts +++ b/app/game-api/src/index.ts @@ -30,6 +30,9 @@ export * from './auction/types.js'; export * from './auction/keys.js'; export * from './auction/scheduler.js'; export * from './auction/worker.js'; +export * from './tournament/keys.js'; +export * from './tournament/store.js'; +export * from './tournament/types.js'; export * from './tournament/worker.js'; // Types for TRPC consumer diff --git a/app/game-api/src/tournament/worker.ts b/app/game-api/src/tournament/worker.ts index af79bfb..9d65372 100644 --- a/app/game-api/src/tournament/worker.ts +++ b/app/game-api/src/tournament/worker.ts @@ -1,15 +1,15 @@ -import { createTournamentRng } from '@sammo-ts/common'; +import { createTournamentRng, type TurnDaemonCommandResult } from '@sammo-ts/common'; import { resolveTournamentBattle } from '@sammo-ts/logic'; import { createGamePostgresConnector, createRedisConnector, resolvePostgresConfigFromEnv, resolveRedisConfigFromEnv, + type GamePrismaClient, } from '@sammo-ts/infra'; import { resolveGameApiConfigFromEnv } from '../config.js'; -import { RedisTurnDaemonTransport } from '../daemon/redisTransport.js'; -import { buildTurnDaemonStreamKeys } from '../daemon/streamKeys.js'; +import { DatabaseTurnDaemonTransport } from '../daemon/databaseTransport.js'; import type { TurnDaemonTransport } from '../daemon/transport.js'; import { buildTournamentKeys } from './keys.js'; import { TournamentStore } from './store.js'; @@ -462,13 +462,30 @@ export const settleTournamentOutcome = async (options: { return null; } - let settledState: TournamentState | null = null; + let settledState = state; + let changed = false; - if (!state.rewardSettled) { + const requireSuccessfulResult = ( + result: TurnDaemonCommandResult | null, + expectedType: TurnDaemonCommandResult['type'] + ): void => { + if (!result) { + throw new Error(`${expectedType} 명령 응답 시간이 초과되었습니다.`); + } + if (result.type !== expectedType) { + throw new Error(`${expectedType} 명령에 잘못된 응답(${result.type})을 받았습니다.`); + } + if (!result.ok) { + throw new Error(`${expectedType} 명령이 실패했습니다: ${result.reason}`); + } + }; + + if (!settledState.rewardSettled) { const matches = await store.getMatches(); const rewardPayload = buildTournamentRewardPayload(matches); - await daemonTransport.sendCommand({ + const result = await daemonTransport.requestCommand({ type: 'tournamentReward', + requestId: `tournament:${state.bettingId ?? `${state.openYear}:${state.openMonth}:${state.type}`}:reward`, tournamentType: state.type, winnerId: rewardPayload.winnerId, runnerUpId: rewardPayload.runnerUpId, @@ -476,46 +493,100 @@ export const settleTournamentOutcome = async (options: { top8: rewardPayload.top8, top4: rewardPayload.top4, }); + requireSuccessfulResult(result, 'tournamentReward'); settledState = { - ...(settledState ?? state), + ...settledState, rewardSettled: true, }; + changed = true; + await store.setState(settledState); } - if (state.bettingId && !state.bettingSettled) { + if (settledState.bettingId && !settledState.bettingSettled) { const bettingEntries = await store.getBettingEntries(); if (bettingEntries.length > 0) { - const payoutInfo = buildBettingPayouts(state.winnerId, bettingEntries); + const payoutInfo = buildBettingPayouts(settledState.winnerId!, bettingEntries); if (payoutInfo.payouts.length > 0) { if (payoutInfo.refundAll) { - await daemonTransport.sendCommand({ + const result = await daemonTransport.requestCommand({ type: 'tournamentRefund', - bettingId: state.bettingId, + requestId: `tournament:${settledState.bettingId}:betting-refund`, + bettingId: settledState.bettingId, refunds: payoutInfo.payouts, reason: 'no_winner', }); + requireSuccessfulResult(result, 'tournamentRefund'); } else { - await daemonTransport.sendCommand({ + const result = await daemonTransport.requestCommand({ type: 'tournamentBettingPayout', - bettingId: state.bettingId, + requestId: `tournament:${settledState.bettingId}:betting-payout`, + bettingId: settledState.bettingId, payouts: payoutInfo.payouts, reason: 'winner_payout', }); + requireSuccessfulResult(result, 'tournamentBettingPayout'); } } } settledState = { - ...(settledState ?? state), + ...settledState, bettingSettled: true, }; - } - - if (settledState) { + changed = true; await store.setState(settledState); } - return settledState; + return changed ? settledState : null; +}; + +const needsSettlement = (state: TournamentState): boolean => + state.stage === 0 && + Boolean(state.winnerId) && + (!state.rewardSettled || (Boolean(state.bettingId) && !state.bettingSettled)); + +export const processTournamentTick = async (options: { + store: TournamentStore; + prisma: GamePrismaClient; + daemonTransport: TurnDaemonTransport; + now?: () => number; +}): Promise => { + const { store, prisma, daemonTransport } = options; + const now = options.now ?? Date.now; + let processedState: TournamentState | null = null; + + await store.withMutationLock(async () => { + const state = await store.getState(); + if (!state || (!state.auto && !needsSettlement(state))) { + return; + } + const nextAt = new Date(state.nextAt).getTime(); + if (state.auto && Number.isFinite(nextAt) && nextAt > now()) { + return; + } + + if (needsSettlement(state)) { + processedState = (await settleTournamentOutcome({ store, daemonTransport, state })) ?? state; + return; + } + + const worldState = await prisma.worldState.findFirst(); + const baseSeed = (worldState?.meta as Record | null)?.hiddenSeed ?? 'tournament'; + let nextState = state; + if (isBattleStage(state.stage)) { + nextState = await applyBattle(store, state, String(baseSeed), daemonTransport); + } else if (isPreBattleStage(state.stage)) { + nextState = await applyPreBattleStage(store, prisma, state, String(baseSeed), daemonTransport); + } + processedState = + (await settleTournamentOutcome({ + store, + daemonTransport, + state: nextState, + })) ?? nextState; + }); + + return processedState; }; export const runTournamentWorker = async (): Promise => { @@ -527,10 +598,7 @@ export const runTournamentWorker = async (): Promise => { await redis.connect(); const store = new TournamentStore(redis.client, buildTournamentKeys(config.profileName)); - const daemonTransport = new RedisTurnDaemonTransport(redis.client, { - keys: buildTurnDaemonStreamKeys(config.profileName), - requestTimeoutMs: config.daemonRequestTimeoutMs, - }); + const daemonTransport = new DatabaseTurnDaemonTransport(postgres.prisma, config.daemonRequestTimeoutMs); const handleExit = async () => { await redis.disconnect(); @@ -541,49 +609,23 @@ export const runTournamentWorker = async (): Promise => { while (true) { const state = await store.getState(); - if (!state || !state.auto) { + if (!state || (!state.auto && !needsSettlement(state))) { await sleepMs(config.tournamentPollMs); continue; } const nextAt = new Date(state.nextAt).getTime(); const now = Date.now(); - if (Number.isFinite(nextAt) && nextAt > now) { + if (state.auto && Number.isFinite(nextAt) && nextAt > now) { await sleepMs(Math.min(config.tournamentPollMs, nextAt - now)); continue; } try { - await store.withMutationLock(async () => { - const lockedState = await store.getState(); - if (!lockedState || !lockedState.auto) { - return; - } - const lockedNextAt = new Date(lockedState.nextAt).getTime(); - if (Number.isFinite(lockedNextAt) && lockedNextAt > Date.now()) { - return; - } - - const worldState = await postgres.prisma.worldState.findFirst(); - const baseSeed = (worldState?.meta as Record | null)?.hiddenSeed ?? 'tournament'; - let nextState = lockedState; - if (isBattleStage(lockedState.stage)) { - nextState = await applyBattle(store, lockedState, String(baseSeed), daemonTransport); - } else if (isPreBattleStage(lockedState.stage)) { - nextState = await applyPreBattleStage( - store, - postgres.prisma, - lockedState, - String(baseSeed), - daemonTransport - ); - } - - await settleTournamentOutcome({ - store, - daemonTransport, - state: nextState, - }); + await processTournamentTick({ + store, + prisma: postgres.prisma, + daemonTransport, }); } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error'; @@ -603,9 +645,9 @@ export const runTournamentWorker = async (): Promise => { }, }, }); + const currentState = (await store.getState()) ?? state; const nextState: TournamentState = { - ...state, - auto: false, + ...currentState, lastError: message, lastErrorAt: now, }; diff --git a/app/game-api/src/tournament/workerHelpers.ts b/app/game-api/src/tournament/workerHelpers.ts index 2519d24..eda261b 100644 --- a/app/game-api/src/tournament/workerHelpers.ts +++ b/app/game-api/src/tournament/workerHelpers.ts @@ -520,8 +520,9 @@ export const buildBettingPayouts = ( const winners = entries.filter((entry) => entry.targetId === winnerId); const winnersTotal = winners.reduce((sum, entry) => sum + entry.amount, 0); if (winnersTotal <= 0) { - const refunds = entries.map((entry) => ({ generalId: entry.generalId, amount: entry.amount })); - return { payouts: refunds, total, refundAll: true }; + // Legacy Betting::_calcRewardExclusive() builds a refund candidate list + // but returns no rewards when nobody selected the winner. + return { payouts: [], total, refundAll: false }; } const ratio = total / winnersTotal; const payouts = winners.map((entry) => ({ diff --git a/app/game-api/test/tournamentWorker.test.ts b/app/game-api/test/tournamentWorker.test.ts index 4a0ca2a..4c89803 100644 --- a/app/game-api/test/tournamentWorker.test.ts +++ b/app/game-api/test/tournamentWorker.test.ts @@ -12,6 +12,7 @@ import type { TournamentState, } from '../src/tournament/types.js'; import { applyBattle, applyPreBattleStage, settleTournamentOutcome } from '../src/tournament/worker.js'; +import { buildBettingPayouts } from '../src/tournament/workerHelpers.js'; import type { TurnDaemonTransport } from '../src/daemon/transport.js'; class MemoryRedis { @@ -209,6 +210,15 @@ const runTournamentToCompletion = async (options: { const delayTick = async (): Promise => new Promise((resolve) => setTimeout(resolve, 0)); describe('tournament worker (in-memory)', () => { + it('당첨자가 없으면 레거시와 같이 베팅금을 지급하거나 환불하지 않는다', () => { + expect( + buildBettingPayouts(10, [ + { generalId: 1, targetId: 11, amount: 100 }, + { generalId: 2, targetId: 12, amount: 200 }, + ]) + ).toEqual({ payouts: [], total: 300, refundAll: false }); + }); + it('locks 64 applicants into eight groups of eight', async () => { const redis = new MemoryRedis(); const store = new TournamentStore(redis, buildTournamentKeys('test-groups')); @@ -284,11 +294,33 @@ describe('tournament worker (in-memory)', () => { const sent: TurnDaemonCommand[] = []; const transport: TurnDaemonTransport = { - sendCommand: async (command) => { + sendCommand: async () => 'unused', + requestCommand: async (command) => { sent.push(command); - return 'ok'; + if (command.type === 'tournamentReward') { + return { + type: 'tournamentReward', + ok: true, + winnerId: command.winnerId, + runnerUpId: command.runnerUpId, + rewarded: 2, + missing: 0, + totalGold: 100, + totalExp: 10, + }; + } + if (command.type === 'tournamentBettingPayout') { + return { + type: 'tournamentBettingPayout', + ok: true, + bettingId: command.bettingId, + processed: command.payouts.length, + missing: 0, + totalPayout: command.payouts.reduce((sum, payout) => sum + payout.amount, 0), + }; + } + return null; }, - requestCommand: async () => null, requestStatus: async () => null, }; @@ -302,6 +334,82 @@ describe('tournament worker (in-memory)', () => { if (bettingCommand && bettingCommand.type === 'tournamentBettingPayout') { expect(bettingCommand.payouts).toEqual([{ generalId: 1, amount: 300 }]); } + expect(await store.getState()).toMatchObject({ + rewardSettled: true, + bettingSettled: true, + }); + }); + + it('정산 응답 실패 시 완료 표시를 남기지 않고 성공한 보상만 재시도에서 제외한다', async () => { + const redis = new MemoryRedis(); + const store = new TournamentStore(redis, buildTournamentKeys('test-bet-retry')); + const state = createTournamentState({ + stage: 0, + auto: false, + winnerId: 10, + bettingId: 124, + rewardSettled: false, + bettingSettled: false, + }); + await store.setMatches([{ id: 1, stage: 10, roundIndex: 0, attackerId: 10, defenderId: 11, winnerId: 10 }]); + await store.setBettingEntries([{ generalId: 1, targetId: 10, amount: 100 }]); + await store.setState(state); + + let payoutAttempts = 0; + const commands: TurnDaemonCommand[] = []; + const transport: TurnDaemonTransport = { + sendCommand: async () => 'unused', + requestCommand: async (command) => { + commands.push(command); + if (command.type === 'tournamentReward') { + return { + type: 'tournamentReward', + ok: true, + winnerId: command.winnerId, + runnerUpId: command.runnerUpId, + rewarded: 2, + missing: 0, + totalGold: 100, + totalExp: 10, + }; + } + if (command.type === 'tournamentBettingPayout') { + payoutAttempts += 1; + if (payoutAttempts === 1) { + return { + type: 'tournamentBettingPayout', + ok: false, + bettingId: command.bettingId, + reason: '일시적 실패', + }; + } + return { + type: 'tournamentBettingPayout', + ok: true, + bettingId: command.bettingId, + processed: 1, + missing: 0, + totalPayout: 100, + }; + } + return null; + }, + requestStatus: async () => null, + }; + + await expect(settleTournamentOutcome({ store, daemonTransport: transport, state })).rejects.toThrow( + '일시적 실패' + ); + const afterFailure = await store.getState(); + expect(afterFailure).toMatchObject({ rewardSettled: true, bettingSettled: false }); + + await settleTournamentOutcome({ store, daemonTransport: transport, state: afterFailure! }); + expect(await store.getState()).toMatchObject({ rewardSettled: true, bettingSettled: true }); + expect(commands.filter((command) => command.type === 'tournamentReward')).toHaveLength(1); + expect(commands.filter((command) => command.type === 'tournamentBettingPayout')).toHaveLength(2); + expect( + commands.filter((command) => command.type === 'tournamentBettingPayout').map((command) => command.requestId) + ).toEqual(['tournament:124:betting-payout', 'tournament:124:betting-payout']); }); it('자동 오픈 후 참가자 보충(NPC/더미 포함)하고 결승까지 진행된다', async () => { diff --git a/tools/integration-tests/test/tournamentLifecycle.test.ts b/tools/integration-tests/test/tournamentLifecycle.test.ts new file mode 100644 index 0000000..6e4aa22 --- /dev/null +++ b/tools/integration-tests/test/tournamentLifecycle.test.ts @@ -0,0 +1,468 @@ +import path from 'node:path'; +import fs from 'node:fs/promises'; +import { execFile } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { createTRPCProxyClient, httpBatchLink } from '@trpc/client'; + +import type { AppRouter as GatewayAppRouter } from '@sammo-ts/gateway-api'; +import { createGatewayApiServer } from '@sammo-ts/gateway-api'; +import type { AppRouter as GameAppRouter } from '@sammo-ts/game-api'; +import { + buildTournamentKeys, + createGameApiServer, + DatabaseTurnDaemonTransport, + processTournamentTick, + TournamentStore, +} from '@sammo-ts/game-api'; +import { createTurnDaemonRuntime } from '@sammo-ts/game-engine'; +import { + createGamePostgresConnector, + createGatewayPostgresConnector, + createRedisConnector, + resolvePostgresConfigFromEnv, + resolveRedisConfigFromEnv, + type GamePrisma, +} from '@sammo-ts/infra'; + +const workspaceRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..'); +const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); + +const parseEnvFile = (rawText: string): Record => { + const env: Record = {}; + for (const line of rawText.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) { + continue; + } + const separator = trimmed.indexOf('='); + if (separator < 0) { + continue; + } + const key = trimmed.slice(0, separator).trim(); + let value = trimmed.slice(separator + 1).trim(); + if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1); + } + env[key] = value; + } + return env; +}; + +const loadEnv = async (): Promise => { + const values = parseEnvFile(await fs.readFile(path.join(workspaceRoot, '.env.ci'), 'utf8')); + for (const [key, value] of Object.entries(values)) { + if (process.env[key] === undefined) { + process.env[key] = value; + } + } + process.env.INTEGRATION_JOIN_ALLOW_CITY ??= 'true'; + process.env.INTEGRATION_WORLD_SEED ??= 'tournament-lifecycle-seed'; +}; + +const execCommand = (command: string, args: string[], env?: NodeJS.ProcessEnv): Promise => + new Promise((resolve, reject) => { + execFile(command, args, { env, cwd: workspaceRoot }, (error, stdout, stderr) => { + if (error) { + reject(new Error(`${command} ${args.join(' ')} failed:\n${stdout}\n${stderr}`)); + return; + } + resolve(); + }); + }); + +const ensureSchema = async (schema: string): Promise => { + const connector = createGatewayPostgresConnector({ + url: resolvePostgresConfigFromEnv({ schema: 'public' }).url, + }); + await connector.connect(); + try { + await connector.prisma.$executeRawUnsafe(`CREATE SCHEMA IF NOT EXISTS "${schema}"`); + } finally { + await connector.disconnect(); + } +}; + +const truncateSchema = async (schema: string): Promise => { + const connector = createGatewayPostgresConnector({ + url: resolvePostgresConfigFromEnv({ schema: 'public' }).url, + }); + await connector.connect(); + try { + const rows = (await connector.prisma.$queryRawUnsafe( + `SELECT tablename FROM pg_tables WHERE schemaname = '${schema}'` + )) as Array<{ tablename: string }>; + if (rows.length === 0) { + return; + } + const tableList = rows.map((row) => `"${schema}"."${row.tablename}"`).join(', '); + await connector.prisma.$executeRawUnsafe(`TRUNCATE TABLE ${tableList} RESTART IDENTITY CASCADE`); + } finally { + await connector.disconnect(); + } +}; + +const resetServices = async (): Promise => { + await ensureSchema('public'); + await ensureSchema('che'); + await execCommand('pnpm', ['--filter', '@sammo-ts/infra', 'prisma:db:push:gateway', '--accept-data-loss'], { + ...process.env, + POSTGRES_SCHEMA: 'public', + }); + await execCommand('pnpm', ['--filter', '@sammo-ts/infra', 'prisma:db:push:game', '--accept-data-loss'], { + ...process.env, + POSTGRES_SCHEMA: 'che', + }); + await truncateSchema('public'); + await truncateSchema('che'); + + const redis = createRedisConnector(resolveRedisConfigFromEnv()); + await redis.connect(); + try { + await redis.client.flushDb(); + } finally { + await redis.disconnect(); + } +}; + +const createGatewayClient = (baseUrl: string, pathName: string, token: { value?: string }) => + createTRPCProxyClient({ + links: [ + httpBatchLink({ + url: `${baseUrl}${pathName}`, + headers: () => (token.value ? { 'x-session-token': token.value } : {}), + }), + ], + }); + +const createGameClient = (baseUrl: string, pathName: string, token: { value?: string }) => + createTRPCProxyClient({ + links: [ + httpBatchLink({ + url: `${baseUrl}${pathName}`, + headers: () => (token.value ? { authorization: `Bearer ${token.value}` } : {}), + }), + ], + }); + +describe('actual tournament lifecycle', () => { + let gatewayServer: Awaited> | null = null; + let gameServer: Awaited> | null = null; + let turnDaemon: Awaited> | null = null; + let turnDaemonLoop: Promise | null = null; + let gameConnector: Awaited> | null = null; + let redisConnector: Awaited> | null = null; + let store: TournamentStore | null = null; + let transport: DatabaseTurnDaemonTransport | null = null; + + const clients = new Map>(); + const generalIds = new Map(); + + beforeAll(async () => { + await loadEnv(); + process.env.SCENARIO = '908'; + process.chdir(workspaceRoot); + await resetServices(); + + gatewayServer = await createGatewayApiServer(); + await gatewayServer.app.listen({ host: gatewayServer.config.host, port: gatewayServer.config.port }); + gameServer = await createGameApiServer(); + await gameServer.app.listen({ host: gameServer.config.host, port: gameServer.config.port }); + + const gatewayUrl = `http://localhost:${gatewayServer.config.port}`; + const gameUrl = `http://localhost:${gameServer.config.port}`; + const adminSession = { value: undefined as string | undefined }; + const gatewayClient = createGatewayClient(gatewayUrl, gatewayServer.config.trpcPath, adminSession); + + const bootstrap = await gatewayClient.auth.bootstrapLocal.mutate({ + token: process.env.GATEWAY_BOOTSTRAP_TOKEN ?? '', + username: 'admin', + password: 'admin-pass-123', + displayName: '관리자', + }); + adminSession.value = bootstrap.sessionToken; + + const users = [ + ['participant', '대회참가자'], + ['bettor-a', '베팅유저A'], + ['bettor-b', '베팅유저B'], + ['no-general', '무장수유저'], + ] as const; + for (const [username, displayName] of users) { + await gatewayClient.admin.users.createLocal.mutate({ + username, + password: `${username}-pass`, + displayName, + }); + } + + await gatewayClient.admin.profiles.upsert.mutate({ + profile: 'che', + scenario: '908', + apiPort: Number(process.env.GAME_API_PORT ?? 14000), + status: 'RUNNING', + }); + await gatewayClient.admin.profiles.installNow.mutate({ + profileName: 'che:908', + install: { + scenarioId: 908, + turnTermMinutes: 1, + sync: false, + fiction: 0, + extend: true, + blockGeneralCreate: 0, + npcMode: 0, + showImgLevel: 0, + tournamentTrig: true, + joinMode: 'full', + autorunUser: null, + }, + }); + + for (const [username, displayName] of users) { + const login = await gatewayClient.auth.login.mutate({ + username, + password: `${username}-pass`, + }); + const gatewayToken = await gatewayClient.auth.issueGameSession.mutate({ + sessionToken: login.sessionToken, + profile: 'che:908', + }); + const accessRef = { value: undefined as string | undefined }; + const client = createGameClient(gameUrl, gameServer.config.trpcPath, accessRef); + const access = await client.auth.exchangeGatewayToken.mutate({ gatewayToken: gatewayToken.gameToken }); + accessRef.value = access.accessToken; + clients.set(username, client); + + if (username !== 'no-general') { + const created = await client.join.createGeneral.mutate({ + name: displayName, + leadership: 55, + strength: 55, + intel: 55, + character: 'Random', + }); + generalIds.set(username, created.generalId); + } + } + + const gameDatabaseUrl = resolvePostgresConfigFromEnv({ schema: 'che' }).url; + const gatewayDatabaseUrl = resolvePostgresConfigFromEnv({ schema: 'public' }).url; + gameConnector = createGamePostgresConnector({ url: gameDatabaseUrl }); + await gameConnector.connect(); + await gameConnector.prisma.general.updateMany({ + where: { id: { in: [...generalIds.values()] } }, + data: { gold: 10_000 }, + }); + const maxGeneralId = (await gameConnector.prisma.general.aggregate({ _max: { id: true } }))._max.id ?? 0; + const npcTemplate = await gameConnector.prisma.general.findFirstOrThrow({ + where: { id: { in: [...generalIds.values()] } }, + }); + const { + id: _templateId, + userId: _templateUserId, + name: _templateName, + createdAt: _templateCreatedAt, + updatedAt: _templateUpdatedAt, + ...npcTemplateData + } = npcTemplate; + const templateMeta = + typeof npcTemplate.meta === 'object' && npcTemplate.meta !== null && !Array.isArray(npcTemplate.meta) + ? npcTemplate.meta + : {}; + await gameConnector.prisma.general.createMany({ + data: Array.from({ length: 48 }, (_, index): GamePrisma.GeneralCreateManyInput => ({ + ...npcTemplateData, + id: maxGeneralId + index + 1, + userId: null, + name: `대회NPC${index + 1}`, + npcState: 2, + leadership: 40 + (index % 31), + strength: 40 + ((index * 3) % 31), + intel: 40 + ((index * 7) % 31), + gold: 10_000, + lastTurn: npcTemplate.lastTurn as GamePrisma.InputJsonValue, + meta: { ...templateMeta, explevel: 20 }, + penalty: npcTemplate.penalty as GamePrisma.InputJsonValue, + })), + }); + + redisConnector = createRedisConnector(resolveRedisConfigFromEnv()); + await redisConnector.connect(); + store = new TournamentStore(redisConnector.client, buildTournamentKeys('che:908')); + transport = new DatabaseTurnDaemonTransport(gameConnector.prisma, 30_000); + + turnDaemon = await createTurnDaemonRuntime({ + profile: 'che', + profileName: 'che:908', + databaseUrl: gameDatabaseUrl, + gatewayDatabaseUrl, + redisUrl: resolveRedisConfigFromEnv().url, + }); + + for (let attempt = 0; attempt < 36; attempt += 1) { + const current = turnDaemon.world.getState().lastTurnTime; + const next = new Date(current.getTime()); + next.setUTCMonth(next.getUTCMonth() + 1); + await turnDaemon.world.advanceMonth(next); + if ((await store.getState())?.stage === 1) { + break; + } + } + expect(await store.getState()).toMatchObject({ stage: 1, auto: true }); + + turnDaemonLoop = turnDaemon.lifecycle.start(); + const status = await transport.requestStatus(10_000); + expect(status).not.toBeNull(); + }, 120_000); + + afterAll(async () => { + if (turnDaemon) { + await turnDaemon.lifecycle.stop('tournament-lifecycle-test'); + await turnDaemon.close(); + await turnDaemonLoop; + } + await redisConnector?.disconnect(); + await gameConnector?.disconnect(); + await gameServer?.app.close(); + await gatewayServer?.app.close(); + }, 30_000); + + it('runs auto-open, enrollment, betting, finals, rewards, and payout through the real daemon', async () => { + if (!store || !transport || !gameConnector) { + throw new Error('integration runtime is not ready'); + } + const participant = clients.get('participant')!; + const bettorA = clients.get('bettor-a')!; + const bettorB = clients.get('bettor-b')!; + const noGeneral = clients.get('no-general')!; + + await expect(noGeneral.tournament.getState.query()).rejects.toThrow(); + await expect(participant.tournament.join.mutate()).resolves.toMatchObject({ ok: true }); + await expect(participant.tournament.join.mutate()).resolves.toMatchObject({ ok: true }); + + for (let steps = 0; steps < 2000; steps += 1) { + const state = await store.getState(); + if (!state) { + throw new Error('tournament state disappeared'); + } + if (state.stage === 6) { + break; + } + await store.setState({ + ...state, + nextAt: new Date(Date.now() - 1_000).toISOString(), + }); + await processTournamentTick({ + store, + prisma: gameConnector.prisma, + daemonTransport: transport, + }); + } + + const bettingState = await store.getState(); + expect(bettingState).toMatchObject({ stage: 6, auto: true }); + const matches = await store.getMatches(); + const candidates = Array.from( + new Set( + matches.filter((match) => match.stage === 7).flatMap((match) => [match.attackerId, match.defenderId]) + ) + ); + expect(candidates).toHaveLength(16); + + const idleDeadline = Date.now() + 60_000; + while (Date.now() < idleDeadline) { + const pending = await gameConnector.prisma.inputEvent.count({ + where: { status: { in: ['PENDING', 'PROCESSING'] } }, + }); + if (pending === 0) { + break; + } + await sleep(100); + } + expect( + await gameConnector.prisma.inputEvent.count({ + where: { status: { in: ['PENDING', 'PROCESSING'] } }, + }) + ).toBe(0); + + for (const targetId of candidates) { + await bettorA.tournament.placeBet.mutate({ targetId, amount: 10 }); + } + await bettorB.tournament.placeBet.mutate({ targetId: candidates[0]!, amount: 100 }); + + const bets = await store.getBettingEntries(); + const bettorAId = generalIds.get('bettor-a')!; + const bettorBId = generalIds.get('bettor-b')!; + expect(bets.filter((entry) => entry.generalId === bettorAId)).toHaveLength(16); + expect(bets.some((entry) => entry.generalId === bettorBId && entry.targetId === candidates[0])).toBe(true); + expect(bets.every((entry) => entry.generalId !== generalIds.get('participant'))).toBe(true); + + const bettorBeforeSettlement = await gameConnector.prisma.general.findUniqueOrThrow({ + where: { id: bettorAId }, + select: { gold: true, meta: true }, + }); + + await store.setState({ + ...bettingState!, + bettingCloseAt: new Date(Date.now() - 1_000).toISOString(), + nextAt: new Date(Date.now() - 1_000).toISOString(), + }); + + for (let steps = 0; steps < 100; steps += 1) { + const state = await store.getState(); + if (!state) { + throw new Error('tournament state disappeared'); + } + if (state.stage === 0 && state.rewardSettled && state.bettingSettled) { + break; + } + if (state.stage > 0) { + await store.setState({ + ...state, + bettingCloseAt: + state.stage === 6 ? new Date(Date.now() - 1_000).toISOString() : state.bettingCloseAt, + nextAt: new Date(Date.now() - 1_000).toISOString(), + }); + } + await processTournamentTick({ + store, + prisma: gameConnector.prisma, + daemonTransport: transport, + }); + } + + const finalState = await store.getState(); + expect(finalState).toMatchObject({ + stage: 0, + auto: false, + rewardSettled: true, + bettingSettled: true, + }); + expect(finalState?.winnerId).toBeTypeOf('number'); + + const bettorAfterSettlement = await gameConnector.prisma.general.findUniqueOrThrow({ + where: { id: bettorAId }, + select: { gold: true, meta: true }, + }); + expect(bettorAfterSettlement.gold).toBeGreaterThan(bettorBeforeSettlement.gold); + expect(bettorAfterSettlement.meta).toMatchObject({ + betgold: 160, + betwin: 1, + }); + expect(Number((bettorAfterSettlement.meta as Record).betwingold)).toBeGreaterThan(0); + + const settlementEvents = await gameConnector.prisma.inputEvent.findMany({ + where: { eventType: { in: ['tournamentReward', 'tournamentBettingPayout'] } }, + select: { eventType: true, status: true, result: true }, + }); + expect(settlementEvents).toEqual( + expect.arrayContaining([ + expect.objectContaining({ eventType: 'tournamentReward', status: 'SUCCEEDED' }), + expect.objectContaining({ eventType: 'tournamentBettingPayout', status: 'SUCCEEDED' }), + ]) + ); + expect(settlementEvents.every((event) => (event.result as { ok?: boolean } | null)?.ok === true)).toBe(true); + }, 120_000); +});