diff --git a/app/game-api/src/router/tournament/index.ts b/app/game-api/src/router/tournament/index.ts index 1ac32210..4124852b 100644 --- a/app/game-api/src/router/tournament/index.ts +++ b/app/game-api/src/router/tournament/index.ts @@ -36,6 +36,7 @@ const zTournamentState = z.object({ bettingCloseAt: z.string().optional(), winnerId: z.number().int().optional(), bettingSettled: z.boolean().optional(), + rewardSettled: z.boolean().optional(), lastError: z.string().optional(), lastErrorAt: z.string().optional(), }); diff --git a/app/game-api/src/tournament/types.ts b/app/game-api/src/tournament/types.ts index 3264c27d..63c83616 100644 --- a/app/game-api/src/tournament/types.ts +++ b/app/game-api/src/tournament/types.ts @@ -13,6 +13,7 @@ export interface TournamentState { bettingCloseAt?: string; winnerId?: number; bettingSettled?: boolean; + rewardSettled?: boolean; lastError?: string; lastErrorAt?: string; } diff --git a/app/game-api/src/tournament/worker.ts b/app/game-api/src/tournament/worker.ts index e2856316..db344f09 100644 --- a/app/game-api/src/tournament/worker.ts +++ b/app/game-api/src/tournament/worker.ts @@ -268,6 +268,42 @@ const buildBettingPayouts = ( return { payouts, total, refundAll: false }; }; +const buildTournamentRewardPayload = ( + matches: TournamentMatchEntry[] +): { top16: number[]; top8: number[]; top4: number[]; winnerId: number; runnerUpId: number } => { + const top16 = new Set(); + const top8 = new Set(); + const top4 = new Set(); + + for (const match of matches) { + if (match.stage === 7) { + top16.add(match.attackerId); + top16.add(match.defenderId); + if (typeof match.winnerId === 'number') { + top8.add(match.winnerId); + } + } + if (match.stage === 8 && typeof match.winnerId === 'number') { + top4.add(match.winnerId); + } + } + + const finalMatch = matches.find((match) => match.stage === 10 && typeof match.winnerId === 'number'); + if (!finalMatch || typeof finalMatch.winnerId !== 'number') { + throw new Error('결승전 결과를 찾을 수 없습니다.'); + } + const winnerId = finalMatch.winnerId; + const runnerUpId = finalMatch.attackerId === winnerId ? finalMatch.defenderId : finalMatch.attackerId; + + return { + top16: Array.from(top16), + top8: Array.from(top8), + top4: Array.from(top4), + winnerId, + runnerUpId, + }; +}; + const applyPreBattleStage = async ( store: TournamentStore, state: TournamentState, @@ -481,39 +517,59 @@ export const runTournamentWorker = async (): Promise => { nextState = await applyPreBattleStage(store, state, String(baseSeed)); } - if ( - nextState.stage === 0 && - nextState.winnerId && - nextState.bettingId && - !nextState.bettingSettled - ) { - const bettingEntries = await store.getBettingEntries(); - if (bettingEntries.length > 0) { - const payoutInfo = buildBettingPayouts(nextState.winnerId, bettingEntries); - if (payoutInfo.payouts.length > 0) { - if (payoutInfo.refundAll) { - await daemonTransport.sendCommand({ - type: 'tournamentRefund', - bettingId: nextState.bettingId, - refunds: payoutInfo.payouts, - reason: 'no_winner', - }); - } else { - await daemonTransport.sendCommand({ - type: 'tournamentBettingPayout', - bettingId: nextState.bettingId, - payouts: payoutInfo.payouts, - reason: 'winner_payout', - }); - } - } + if (nextState.stage === 0 && nextState.winnerId) { + let settledState: TournamentState | null = null; + + if (!nextState.rewardSettled) { + const matches = await store.getMatches(); + const rewardPayload = buildTournamentRewardPayload(matches); + await daemonTransport.sendCommand({ + type: 'tournamentReward', + tournamentType: nextState.type, + winnerId: rewardPayload.winnerId, + runnerUpId: rewardPayload.runnerUpId, + top16: rewardPayload.top16, + top8: rewardPayload.top8, + top4: rewardPayload.top4, + }); + settledState = { + ...(settledState ?? nextState), + rewardSettled: true, + }; } - const settledState: TournamentState = { - ...nextState, - bettingSettled: true, - }; - await store.setState(settledState); + if (nextState.bettingId && !nextState.bettingSettled) { + const bettingEntries = await store.getBettingEntries(); + if (bettingEntries.length > 0) { + const payoutInfo = buildBettingPayouts(nextState.winnerId, bettingEntries); + if (payoutInfo.payouts.length > 0) { + if (payoutInfo.refundAll) { + await daemonTransport.sendCommand({ + type: 'tournamentRefund', + bettingId: nextState.bettingId, + refunds: payoutInfo.payouts, + reason: 'no_winner', + }); + } else { + await daemonTransport.sendCommand({ + type: 'tournamentBettingPayout', + bettingId: nextState.bettingId, + payouts: payoutInfo.payouts, + reason: 'winner_payout', + }); + } + } + } + + settledState = { + ...(settledState ?? nextState), + bettingSettled: true, + }; + } + + if (settledState) { + await store.setState(settledState); + } } } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error'; diff --git a/app/game-engine/src/lifecycle/redisCommandStream.ts b/app/game-engine/src/lifecycle/redisCommandStream.ts index 5253f889..66f4232b 100644 --- a/app/game-engine/src/lifecycle/redisCommandStream.ts +++ b/app/game-engine/src/lifecycle/redisCommandStream.ts @@ -256,6 +256,32 @@ const normalizeCommand = (envelope: TurnDaemonCommandEnvelope): TurnDaemonComman payouts, }; } + case 'tournamentReward': { + if (typeof command.winnerId !== 'number' || typeof command.runnerUpId !== 'number') { + return null; + } + if (!Array.isArray(command.top16) || !Array.isArray(command.top8) || !Array.isArray(command.top4)) { + return null; + } + const normalizeIds = (list: unknown[]): number[] => + list.filter((entry): entry is number => typeof entry === 'number' && Number.isFinite(entry)); + const top16 = normalizeIds(command.top16); + const top8 = normalizeIds(command.top8); + const top4 = normalizeIds(command.top4); + if (top16.length === 0) { + return null; + } + return { + type: 'tournamentReward', + requestId: envelope.requestId, + tournamentType: typeof command.tournamentType === 'number' ? command.tournamentType : 0, + winnerId: command.winnerId, + runnerUpId: command.runnerUpId, + top16, + top8, + top4, + }; + } case 'getStatus': { const requestId = typeof command.requestId === 'string' ? command.requestId : envelope.requestId; return { type: 'getStatus', requestId }; diff --git a/app/game-engine/src/tournament/finalizer.ts b/app/game-engine/src/tournament/finalizer.ts new file mode 100644 index 00000000..74853588 --- /dev/null +++ b/app/game-engine/src/tournament/finalizer.ts @@ -0,0 +1,229 @@ +import { JosaUtil, asRecord } from '@sammo-ts/common'; +import { createGamePostgresConnector } from '@sammo-ts/infra'; +import { ActionLogger, LogFormat, type TournamentType } from '@sammo-ts/logic'; + +import type { TurnDaemonCommand, TurnDaemonCommandResult, TurnDaemonHooks } from '../lifecycle/types.js'; +import type { InMemoryTurnWorld } from '../turn/inMemoryWorld.js'; + +export interface TournamentRewardFinalizer { + finalize(command: Extract): Promise; + close(): Promise; +} + +const resolveTournamentLabel = (type: TournamentType): string => { + switch (type) { + case 1: + return '통솔전'; + case 2: + return '일기토'; + case 3: + return '설전'; + case 0: + default: + return '전력전'; + } +}; + +const resolveNumber = (source: Record, keys: string[], fallback: number): number => { + for (const key of keys) { + const value = source[key]; + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + } + return fallback; +}; + +const pushLogs = (world: InMemoryTurnWorld, logs: ReturnType): void => { + if (logs.length === 0) { + return; + } + for (const entry of logs) { + world.pushLog(entry); + } +}; + +const flushWorld = async (world: InMemoryTurnWorld, hooks?: TurnDaemonHooks): Promise => { + if (!hooks?.flushChanges) { + return; + } + const state = world.getState(); + await hooks.flushChanges({ + lastTurnTime: state.lastTurnTime.toISOString(), + processedGenerals: 0, + processedTurns: 0, + durationMs: 0, + partial: false, + checkpoint: world.getCheckpoint(), + }); +}; + +export const createTournamentRewardFinalizer = async (options: { + databaseUrl: string; + world: InMemoryTurnWorld; + hooks?: TurnDaemonHooks; +}): Promise => { + const connector = createGamePostgresConnector({ url: options.databaseUrl }); + await connector.connect(); + const prisma = connector.prisma; + + const finalize = async ( + command: Extract + ): Promise => { + const { world, hooks } = options; + const { winnerId, runnerUpId } = command; + const rewardMap = new Map< + number, + { gold: number; exp: number; label: string; inheritPoint: number } + >(); + + const applyTier = ( + ids: number[], + tier: { gold: number; exp: number; label: string; inheritPoint: number } + ): void => { + for (const id of new Set(ids)) { + const current = rewardMap.get(id) ?? { gold: 0, exp: 0, label: tier.label, inheritPoint: 0 }; + rewardMap.set(id, { + gold: current.gold + tier.gold, + exp: current.exp + tier.exp, + label: tier.label, + inheritPoint: tier.inheritPoint > 0 ? tier.inheritPoint : current.inheritPoint, + }); + } + }; + + const constValues = asRecord(world.getScenarioConfig().const ?? {}); + const develCost = resolveNumber(constValues, ['develCost', 'develcost', 'develrate'], 0); + + applyTier(command.top16, { gold: develCost, exp: 25, label: '16강 진출', inheritPoint: 10 }); + applyTier(command.top8, { gold: develCost * 2, exp: 50, label: '8강 진출', inheritPoint: 0 }); + applyTier(command.top4, { gold: develCost * 3, exp: 50, label: '4강 진출', inheritPoint: 10 }); + applyTier([runnerUpId], { gold: develCost * 6, exp: 100, label: '준우승', inheritPoint: 50 }); + applyTier([winnerId], { gold: develCost * 8, exp: 200, label: '우승', inheritPoint: 100 }); + + if (rewardMap.size === 0) { + return { + type: 'tournamentReward', + ok: false, + winnerId, + runnerUpId, + reason: '보상 대상이 없습니다.', + }; + } + + const nameMap = new Map(); + const generals = await prisma.general.findMany({ + where: { id: { in: Array.from(rewardMap.keys()) } }, + select: { id: true, userId: true, name: true }, + }); + const userMap = new Map(); + for (const general of generals) { + nameMap.set(general.id, general.name); + if (general.userId) { + userMap.set(general.id, general.userId); + } + } + + const tournamentLabel = resolveTournamentLabel(command.tournamentType as TournamentType); + const logs: ReturnType = []; + let rewarded = 0; + let missing = 0; + let totalGold = 0; + let totalExp = 0; + + for (const [generalId, reward] of rewardMap) { + const general = world.getGeneralById(generalId); + if (!general) { + missing += 1; + continue; + } + world.updateGeneral(generalId, { + gold: general.gold + reward.gold, + experience: general.experience + reward.exp, + }); + totalGold += reward.gold; + totalExp += reward.exp; + rewarded += 1; + + const rewardText = reward.gold.toLocaleString('ko-KR'); + const logger = new ActionLogger({ generalId, nationId: general.nationId }); + logger.pushGeneralActionLog( + `${tournamentLabel} 대회의 ${reward.label}로 ${rewardText}의 상금, 약간의 명성 획득!`, + LogFormat.PLAIN + ); + logs.push(...logger.flush()); + } + + const winnerName = nameMap.get(winnerId); + const runnerUpName = nameMap.get(runnerUpId); + const winnerReward = rewardMap.get(winnerId)?.gold ?? 0; + const runnerUpReward = rewardMap.get(runnerUpId)?.gold ?? 0; + if (winnerName) { + const winnerLogger = new ActionLogger({ generalId: winnerId }); + winnerLogger.pushGeneralHistoryLog(`${tournamentLabel} 대회에서 우승`); + logs.push(...winnerLogger.flush()); + } + if (runnerUpName) { + const runnerLogger = new ActionLogger({ generalId: runnerUpId }); + runnerLogger.pushGeneralHistoryLog(`${tournamentLabel} 대회에서 준우승`); + logs.push(...runnerLogger.flush()); + } + if (winnerName && runnerUpName) { + const globalLogger = new ActionLogger(); + const josaWinner = JosaUtil.pick(winnerName, '이'); + const josaRunner = JosaUtil.pick(runnerUpName, '이'); + const winnerRewardText = winnerReward.toLocaleString('ko-KR'); + const runnerRewardText = runnerUpReward.toLocaleString('ko-KR'); + globalLogger.pushGlobalHistoryLog( + `【대회】${tournamentLabel} 대회에서 ${winnerName}${josaWinner} 우승, ${runnerUpName}${josaRunner} 준우승을 차지하여 천하에 이름을 떨칩니다!`, + LogFormat.YEAR_MONTH + ); + globalLogger.pushGlobalHistoryLog( + `【대회】${tournamentLabel} 대회의 우승자에게는 ${winnerRewardText}, 준우승자에겐 ${runnerRewardText}의 상금과 약간의 명성이 주어집니다!`, + LogFormat.YEAR_MONTH + ); + logs.push(...globalLogger.flush()); + } + + pushLogs(world, logs); + + const pointUpdates = Array.from(rewardMap.entries()) + .filter(([, reward]) => reward.inheritPoint > 0) + .map(([generalId, reward]) => ({ + generalId, + userId: userMap.get(generalId), + value: reward.inheritPoint, + })) + .filter((entry) => !!entry.userId); + + for (const entry of pointUpdates) { + await prisma.inheritancePoint.upsert({ + where: { + userId_key: { userId: entry.userId!, key: 'tournament' }, + }, + update: { value: { increment: entry.value } }, + create: { userId: entry.userId!, key: 'tournament', value: entry.value }, + }); + } + + await flushWorld(world, hooks); + + return { + type: 'tournamentReward', + ok: true, + winnerId, + runnerUpId, + rewarded, + missing, + totalGold, + totalExp, + }; + }; + + return { + finalize, + close: async () => { + await connector.disconnect(); + }, + }; +}; \ No newline at end of file diff --git a/app/game-engine/src/turn/turnDaemon.ts b/app/game-engine/src/turn/turnDaemon.ts index 1fe40453..76b5e2d5 100644 --- a/app/game-engine/src/turn/turnDaemon.ts +++ b/app/game-engine/src/turn/turnDaemon.ts @@ -27,6 +27,7 @@ import { loadTurnWorldFromDatabase } from './worldLoader.js'; import { shouldUseAi } from './ai/generalAi.js'; import { createUnificationHandler } from './unificationHandler.js'; import { createAuctionFinalizer } from '../auction/finalizer.js'; +import { createTournamentRewardFinalizer } from '../tournament/finalizer.js'; export interface TurnDaemonRuntimeOptions { profile: string; @@ -172,6 +173,7 @@ export const createTurnDaemonRuntime = async (options: TurnDaemonRuntimeOptions) let publishRealtimeEvent: ((event: RealtimeEvent) => Promise) | null = null; let close = async () => {}; let auctionFinalizer: Awaited> | null = null; + let tournamentRewardFinalizer: Awaited> | null = null; let redisCommandStream: RedisTurnDaemonCommandStream | null = null; let redisConnector: ReturnType | null = null; let pauseGate: (() => Promise) | undefined; @@ -196,6 +198,11 @@ export const createTurnDaemonRuntime = async (options: TurnDaemonRuntimeOptions) world, hooks: dbHooks.hooks, }); + tournamentRewardFinalizer = await createTournamentRewardFinalizer({ + databaseUrl: options.databaseUrl, + world, + hooks: dbHooks.hooks, + }); hooks = { ...dbHooks.hooks, onRunError: async (error) => { @@ -207,6 +214,9 @@ export const createTurnDaemonRuntime = async (options: TurnDaemonRuntimeOptions) if (auctionFinalizer) { await auctionFinalizer.close(); } + if (tournamentRewardFinalizer) { + await tournamentRewardFinalizer.close(); + } await dbHooks.close(); if (reservedTurnStoreHandle) { await reservedTurnStoreHandle.close(); @@ -292,6 +302,7 @@ export const createTurnDaemonRuntime = async (options: TurnDaemonRuntimeOptions) world, hooks, auctionFinalizer: auctionFinalizer ?? undefined, + tournamentRewardFinalizer: tournamentRewardFinalizer ?? undefined, }); const defaultBudget: TurnRunBudget = options.defaultBudget ?? { diff --git a/app/game-engine/src/turn/worldCommandHandler.ts b/app/game-engine/src/turn/worldCommandHandler.ts index 0772ea1d..de02b227 100644 --- a/app/game-engine/src/turn/worldCommandHandler.ts +++ b/app/game-engine/src/turn/worldCommandHandler.ts @@ -30,12 +30,17 @@ interface CommandHandlerContext { world: InMemoryTurnWorld; hooks?: TurnDaemonHooks; auctionFinalizer?: AuctionFinalizer; + tournamentRewardFinalizer?: TournamentRewardFinalizer; } interface AuctionFinalizer { finalize(auctionId: number): Promise; } +interface TournamentRewardFinalizer { + finalize(command: Extract): Promise; +} + async function handleTroopJoin( ctx: CommandHandlerContext, command: Extract @@ -547,12 +552,34 @@ async function handleTournamentBettingPayout( }; } +async function handleTournamentReward( + ctx: CommandHandlerContext, + command: Extract +): Promise { + if (!ctx.tournamentRewardFinalizer) { + return { + type: 'tournamentReward', + ok: false, + winnerId: command.winnerId, + runnerUpId: command.runnerUpId, + reason: '보상 처리기가 준비되지 않았습니다.', + }; + } + return ctx.tournamentRewardFinalizer.finalize(command); +} + export const createTurnDaemonCommandHandler = (options: { world: InMemoryTurnWorld; hooks?: TurnDaemonHooks; auctionFinalizer?: AuctionFinalizer; + tournamentRewardFinalizer?: TournamentRewardFinalizer; }): TurnDaemonCommandHandler => { - const ctx = { world: options.world, hooks: options.hooks, auctionFinalizer: options.auctionFinalizer }; + const ctx = { + world: options.world, + hooks: options.hooks, + auctionFinalizer: options.auctionFinalizer, + tournamentRewardFinalizer: options.tournamentRewardFinalizer, + }; return { handle: async (command): Promise => { @@ -585,6 +612,8 @@ export const createTurnDaemonCommandHandler = (options: { return handleTournamentRefund(ctx, command); case 'tournamentBettingPayout': return handleTournamentBettingPayout(ctx, command); + case 'tournamentReward': + return handleTournamentReward(ctx, command); default: return null; } diff --git a/packages/common/src/turnDaemon/types.ts b/packages/common/src/turnDaemon/types.ts index d37ba537..c5d56a7d 100644 --- a/packages/common/src/turnDaemon/types.ts +++ b/packages/common/src/turnDaemon/types.ts @@ -105,6 +105,16 @@ export type TurnDaemonCommand = generalId: number; amount: number; }>; + } + | { + type: 'tournamentReward'; + requestId?: string; + tournamentType: number; + winnerId: number; + runnerUpId: number; + top16: number[]; + top8: number[]; + top4: number[]; }; export type TurnDaemonCommandResult = @@ -166,21 +176,38 @@ export type TurnDaemonCommandResult = ok: false; bettingId?: number; reason: string; - } - | { - type: 'tournamentBettingPayout'; - ok: true; - bettingId?: number; - processed: number; - missing: number; - totalPayout: number; - } - | { - type: 'tournamentBettingPayout'; - ok: false; - bettingId?: number; - reason: string; - }; + } + | { + type: 'tournamentBettingPayout'; + ok: true; + bettingId?: number; + processed: number; + missing: number; + totalPayout: number; + } + | { + type: 'tournamentBettingPayout'; + ok: false; + bettingId?: number; + reason: string; + } + | { + type: 'tournamentReward'; + ok: true; + winnerId: number; + runnerUpId: number; + rewarded: number; + missing: number; + totalGold: number; + totalExp: number; + } + | { + type: 'tournamentReward'; + ok: false; + winnerId: number; + runnerUpId: number; + reason: string; + }; export type TurnDaemonEvent = | { type: 'status'; requestId?: string; status: TurnDaemonStatus }