diff --git a/app/game-api/src/tournament/worker.ts b/app/game-api/src/tournament/worker.ts index 5c7b30d..bbcca50 100644 --- a/app/game-api/src/tournament/worker.ts +++ b/app/game-api/src/tournament/worker.ts @@ -10,10 +10,47 @@ import { import { resolveGameApiConfigFromEnv } from '../config.js'; import { RedisTurnDaemonTransport } from '../daemon/redisTransport.js'; import { buildTurnDaemonStreamKeys } from '../daemon/streamKeys.js'; +import type { TurnDaemonTransport } from '../daemon/transport.js'; import { buildTournamentKeys } from './keys.js'; import { TournamentStore } from './store.js'; import type { TournamentBetEntry, TournamentMatchEntry, TournamentParticipantEntry, TournamentState } from './types.js'; +type TournamentPrismaClient = { + general: { + findMany: (args: { + where: Record; + select: Record; + }) => Promise< + Array<{ + id: number; + name: string; + leadership: number; + strength: number; + intel: number; + meta: unknown; + npcState: number; + gold?: number; + }> + >; + update: (args: { where: { id: number }; data: { gold: number } }) => Promise; + }; + worldState: { + findFirst: () => Promise<{ meta?: unknown; currentYear?: number } | null>; + }; + $transaction: (actions: Promise[]) => Promise; + errorLog: { + create: (args: { + data: { + category: string; + source: string; + message: string; + trace?: string; + context?: Record; + }; + }) => Promise; + }; +}; + const sleepMs = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); const isBattleStage = (stage: number): boolean => stage >= 7 && stage <= 10; @@ -125,7 +162,7 @@ const selectWeighted = (rng: ReturnType, pool: Ar }; const fillParticipants = async (options: { - prisma: ReturnType['prisma']; + prisma: TournamentPrismaClient; state: TournamentState; baseSeed: string; current: TournamentParticipantEntry[]; @@ -478,7 +515,7 @@ const buildNextMatches = ( return result; }; -const applyBattle = async ( +export const applyBattle = async ( store: TournamentStore, state: TournamentState, baseSeed: string @@ -585,7 +622,7 @@ const applyBattle = async ( return nextState; }; -const buildBettingPayouts = ( +export const buildBettingPayouts = ( winnerId: number, entries: TournamentBetEntry[] ): { payouts: Array<{ generalId: number; amount: number }>; total: number; refundAll: boolean } => { @@ -607,7 +644,7 @@ const buildBettingPayouts = ( return { payouts, total, refundAll: false }; }; -const buildTournamentRewardPayload = ( +export const buildTournamentRewardPayload = ( matches: TournamentMatchEntry[] ): { top16: number[]; top8: number[]; top4: number[]; winnerId: number; runnerUpId: number } => { const top16 = new Set(); @@ -654,7 +691,7 @@ const resolveNumber = (source: Record, keys: string[], fallback }; const seedNpcBets = async (options: { - prisma: ReturnType['prisma']; + prisma: TournamentPrismaClient; store: TournamentStore; state: TournamentState; baseSeed: string; @@ -722,9 +759,9 @@ const seedNpcBets = async (options: { await store.setBettingEntries(entries); }; -const applyPreBattleStage = async ( +export const applyPreBattleStage = async ( store: TournamentStore, - prisma: ReturnType['prisma'], + prisma: TournamentPrismaClient, state: TournamentState, baseSeed: string ): Promise => { @@ -980,6 +1017,72 @@ const applyPreBattleStage = async ( return state; }; +export const settleTournamentOutcome = async (options: { + store: TournamentStore; + daemonTransport: TurnDaemonTransport; + state: TournamentState; +}): Promise => { + const { store, daemonTransport, state } = options; + if (state.stage !== 0 || !state.winnerId) { + return null; + } + + let settledState: TournamentState | null = null; + + if (!state.rewardSettled) { + const matches = await store.getMatches(); + const rewardPayload = buildTournamentRewardPayload(matches); + await daemonTransport.sendCommand({ + type: 'tournamentReward', + tournamentType: state.type, + winnerId: rewardPayload.winnerId, + runnerUpId: rewardPayload.runnerUpId, + top16: rewardPayload.top16, + top8: rewardPayload.top8, + top4: rewardPayload.top4, + }); + settledState = { + ...(settledState ?? state), + rewardSettled: true, + }; + } + + if (state.bettingId && !state.bettingSettled) { + const bettingEntries = await store.getBettingEntries(); + if (bettingEntries.length > 0) { + const payoutInfo = buildBettingPayouts(state.winnerId, bettingEntries); + if (payoutInfo.payouts.length > 0) { + if (payoutInfo.refundAll) { + await daemonTransport.sendCommand({ + type: 'tournamentRefund', + bettingId: state.bettingId, + refunds: payoutInfo.payouts, + reason: 'no_winner', + }); + } else { + await daemonTransport.sendCommand({ + type: 'tournamentBettingPayout', + bettingId: state.bettingId, + payouts: payoutInfo.payouts, + reason: 'winner_payout', + }); + } + } + } + + settledState = { + ...(settledState ?? state), + bettingSettled: true, + }; + } + + if (settledState) { + await store.setState(settledState); + } + + return settledState; +}; + export const runTournamentWorker = async (): Promise => { const config = resolveGameApiConfigFromEnv(); @@ -1026,60 +1129,11 @@ export const runTournamentWorker = async (): Promise => { nextState = await applyPreBattleStage(store, postgres.prisma, state, String(baseSeed)); } - 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, - }; - } - - 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); - } - } + await settleTournamentOutcome({ + store, + daemonTransport, + state: nextState, + }); } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error'; const trace = error instanceof Error ? error.stack : undefined; diff --git a/app/game-api/test/tournamentWorker.test.ts b/app/game-api/test/tournamentWorker.test.ts new file mode 100644 index 0000000..77d19e2 --- /dev/null +++ b/app/game-api/test/tournamentWorker.test.ts @@ -0,0 +1,358 @@ +import { describe, expect, it } from 'vitest'; +import { TournamentType } from '@sammo-ts/logic'; +import type { TurnDaemonCommand } from '@sammo-ts/common'; + +import { TournamentStore } from '../src/tournament/store.js'; +import { buildTournamentKeys } from '../src/tournament/keys.js'; +import type { TournamentBetEntry, TournamentMatchEntry, TournamentParticipantEntry, TournamentState } from '../src/tournament/types.js'; +import { + applyBattle, + applyPreBattleStage, + settleTournamentOutcome, +} from '../src/tournament/worker.js'; +import type { TurnDaemonTransport } from '../src/daemon/transport.js'; +import { createTournamentAutoStartHandler } from '../../game-engine/src/turn/tournamentAutoStart.js'; + +class MemoryRedis { + private readonly store = new Map(); + + async get(key: string): Promise { + return this.store.get(key) ?? null; + } + + async set(key: string, value: string): Promise { + this.store.set(key, value); + return 'OK'; + } +} + +const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null; + +const createTournamentState = (overrides: Partial = {}): TournamentState => { + const now = new Date().toISOString(); + return { + stage: 1, + phase: 0, + type: TournamentType.TOTAL, + auto: true, + openYear: 1, + openMonth: 2, + termSeconds: 10, + nextAt: now, + bettingId: undefined, + bettingCloseAt: undefined, + winnerId: undefined, + bettingSettled: false, + rewardSettled: false, + participantsLockedAt: undefined, + lastError: undefined, + lastErrorAt: undefined, + ...overrides, + }; +}; + +const createParticipants = (high: number, mid: number, low: number): TournamentParticipantEntry[] => { + const result: TournamentParticipantEntry[] = []; + let cursor = 1; + for (let i = 0; i < high; i += 1) { + result.push({ + id: cursor, + name: `상급${cursor}`, + leadership: 90, + strength: 90, + intel: 90, + level: 50, + }); + cursor += 1; + } + for (let i = 0; i < mid; i += 1) { + result.push({ + id: cursor, + name: `중급${cursor}`, + leadership: 60, + strength: 60, + intel: 60, + level: 30, + }); + cursor += 1; + } + for (let i = 0; i < low; i += 1) { + result.push({ + id: cursor, + name: `하급${cursor}`, + leadership: 30, + strength: 30, + intel: 30, + level: 10, + }); + cursor += 1; + } + return result; +}; + +const createPrismaMock = (options: { + applicants?: Array<{ + id: number; + name: string; + leadership: number; + strength: number; + intel: number; + meta: Record; + npcState: number; + }>; + npcs?: Array<{ + id: number; + name: string; + leadership: number; + strength: number; + intel: number; + meta: Record; + npcState: number; + }>; + npcBetting?: Array<{ + id: number; + name: string; + leadership: number; + strength: number; + intel: number; + meta: Record; + npcState: number; + gold: number; + }>; + baseSeed?: string; + currentYear?: number; +}) => { + const applicants = options.applicants ?? []; + const npcs = options.npcs ?? []; + const npcBetting = options.npcBetting ?? []; + + return { + general: { + findMany: async (args: { where: Record; select: Record }) => { + const where = args.where; + if (isRecord(where) && 'meta' in where) { + return applicants.filter((entry) => { + const meta = isRecord(entry.meta) ? entry.meta : {}; + return meta.tnmt === 1; + }); + } + if (isRecord(where)) { + const npcState = isRecord(where.npcState) ? where.npcState : null; + if (isRecord(npcState) && typeof npcState.gte === 'number') { + const gold = isRecord(where.gold) ? where.gold : null; + if (isRecord(gold) && typeof gold.gte === 'number') { + return npcBetting; + } + return npcs; + } + } + return []; + }, + update: async () => ({ ok: true }), + }, + worldState: { + findFirst: async () => ({ + meta: { hiddenSeed: options.baseSeed ?? 'seed' }, + currentYear: options.currentYear ?? 1, + }), + }, + $transaction: async (actions: Promise[]) => Promise.all(actions), + errorLog: { + create: async () => ({ ok: true }), + }, + }; +}; + +const runTournamentToCompletion = async (options: { + store: TournamentStore; + prisma: ReturnType; + baseSeed: string; +}): Promise => { + let state = await options.store.getState(); + if (!state) { + throw new Error('토너먼트 상태가 없습니다.'); + } + + for (let i = 0; i < 2000; i += 1) { + if (state.stage === 0) { + return state; + } + if (state.stage === 6) { + const closedAt = new Date(Date.now() - 1000).toISOString(); + state = { ...state, bettingCloseAt: closedAt, nextAt: closedAt }; + await options.store.setState(state); + } + if (state.stage >= 1 && state.stage <= 6) { + state = await applyPreBattleStage(options.store, options.prisma, state, options.baseSeed); + continue; + } + if (state.stage >= 7 && state.stage <= 10) { + state = await applyBattle(options.store, state, options.baseSeed); + continue; + } + break; + } + + throw new Error('토너먼트가 결승까지 진행되지 않았습니다.'); +}; + +const delayTick = async (): Promise => new Promise((resolve) => setTimeout(resolve, 0)); + +describe('tournament worker (in-memory)', () => { + it('64명(상/중/하 스탯)으로 결승까지 진행된다', async () => { + const redis = new MemoryRedis(); + const store = new TournamentStore(redis, buildTournamentKeys('test')); + const participants = createParticipants(16, 16, 32); + const state = createTournamentState({ stage: 1 }); + await store.setParticipants(participants); + await store.setState(state); + + const prisma = createPrismaMock({ baseSeed: 'seed' }); + const finalState = await runTournamentToCompletion({ store, prisma, baseSeed: 'seed' }); + const matches = await store.getMatches(); + + expect(finalState.stage).toBe(0); + expect(finalState.winnerId).toBeDefined(); + expect(matches.some((match) => match.stage === 10 && match.winnerId)).toBe(true); + }); + + it('우승 결과에 따라 베팅 정산 명령이 생성된다', async () => { + const redis = new MemoryRedis(); + const store = new TournamentStore(redis, buildTournamentKeys('test-bet')); + const matches: TournamentMatchEntry[] = [ + { id: 1, stage: 10, roundIndex: 0, attackerId: 10, defenderId: 11, winnerId: 10 }, + ]; + const entries: TournamentBetEntry[] = [ + { generalId: 1, targetId: 10, amount: 100 }, + { generalId: 2, targetId: 11, amount: 200 }, + ]; + const state = createTournamentState({ + stage: 0, + auto: false, + winnerId: 10, + bettingId: 123, + rewardSettled: false, + bettingSettled: false, + }); + await store.setMatches(matches); + await store.setBettingEntries(entries); + await store.setState(state); + + const sent: TurnDaemonCommand[] = []; + const transport: TurnDaemonTransport = { + sendCommand: async (command) => { + sent.push(command); + return 'ok'; + }, + requestCommand: async () => null, + requestStatus: async () => null, + }; + + await settleTournamentOutcome({ store, daemonTransport: transport, state }); + + const rewardCommand = sent.find((command) => command.type === 'tournamentReward'); + const bettingCommand = sent.find((command) => command.type === 'tournamentBettingPayout'); + + expect(rewardCommand).toBeDefined(); + expect(bettingCommand).toBeDefined(); + if (bettingCommand && bettingCommand.type === 'tournamentBettingPayout') { + expect(bettingCommand.payouts).toEqual([{ generalId: 1, amount: 300 }]); + } + }); + + it('자동 오픈 후 참가자 보충(NPC/더미 포함)하고 결승까지 진행된다', async () => { + const redis = new MemoryRedis(); + const handler = createTournamentAutoStartHandler({ + profileName: 'auto', + getRedisClient: () => redis, + getWorldConfig: () => ({ tournamentTrig: true }), + getTickSeconds: () => 600, + }); + handler.onMonthChanged?.({ + previousYear: 1, + previousMonth: 1, + currentYear: 1, + currentMonth: 2, + turnTime: new Date(), + }); + await delayTick(); + + const store = new TournamentStore(redis, buildTournamentKeys('auto')); + const state = await store.getState(); + expect(state?.stage).toBe(1); + + const autoApplicants = [ + { + id: 1, + name: '자동참가1', + leadership: 70, + strength: 70, + intel: 70, + meta: { tnmt: 1, explevel: 20 }, + npcState: 0, + }, + { + id: 2, + name: '자동참가2', + leadership: 65, + strength: 65, + intel: 65, + meta: { tnmt: 1, explevel: 18 }, + npcState: 0, + }, + { + id: 99, + name: '비자동참가', + leadership: 80, + strength: 80, + intel: 80, + meta: { tnmt: 0, explevel: 25 }, + npcState: 0, + }, + ]; + const npcs = [ + { + id: 1001, + name: 'NPC1', + leadership: 40, + strength: 40, + intel: 40, + meta: { explevel: 12 }, + npcState: 2, + }, + { + id: 1002, + name: 'NPC2', + leadership: 35, + strength: 35, + intel: 35, + meta: { explevel: 10 }, + npcState: 2, + }, + ]; + + const prisma = createPrismaMock({ + applicants: autoApplicants, + npcs, + baseSeed: 'seed', + }); + + const initialState = state ?? createTournamentState({ stage: 1 }); + await store.setState(initialState); + const afterJoin = await applyPreBattleStage(store, prisma, initialState, 'seed'); + const participants = await store.getParticipants(); + + expect(afterJoin.stage).toBe(2); + expect(participants).toHaveLength(64); + expect(participants.some((entry) => entry.id === 1)).toBe(true); + expect(participants.some((entry) => entry.id === 2)).toBe(true); + expect(participants.some((entry) => entry.id === 99)).toBe(false); + expect(participants.some((entry) => entry.id === 1001)).toBe(true); + expect(participants.some((entry) => entry.id < 0)).toBe(true); + + await store.setState(afterJoin); + const finalState = await runTournamentToCompletion({ store, prisma, baseSeed: 'seed' }); + expect(finalState.stage).toBe(0); + expect(finalState.winnerId).toBeDefined(); + }); +});