From b6164868b7b7d72ebd4f83a7b37729160fc5534b Mon Sep 17 00:00:00 2001 From: Hide_D Date: Fri, 23 Jan 2026 16:52:31 +0000 Subject: [PATCH] =?UTF-8?q?=ED=86=A0=EB=84=88=EB=A8=BC=ED=8A=B8=20?= =?UTF-8?q?=EC=A7=84=ED=96=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/game-api/src/tournament/types.ts | 2 + app/game-api/src/tournament/worker.ts | 269 +++++++++++++++++++++++++- 2 files changed, 263 insertions(+), 8 deletions(-) diff --git a/app/game-api/src/tournament/types.ts b/app/game-api/src/tournament/types.ts index 7f1e142..660413d 100644 --- a/app/game-api/src/tournament/types.ts +++ b/app/game-api/src/tournament/types.ts @@ -10,6 +10,8 @@ export interface TournamentState { termSeconds: number; nextAt: string; bettingId?: number; + bettingCloseAt?: string; + winnerId?: number; lastError?: string; lastErrorAt?: string; } diff --git a/app/game-api/src/tournament/worker.ts b/app/game-api/src/tournament/worker.ts index a16903e..1b0183b 100644 --- a/app/game-api/src/tournament/worker.ts +++ b/app/game-api/src/tournament/worker.ts @@ -1,4 +1,5 @@ -import { resolveTournamentBattle } from '@sammo-ts/logic'; +import { createTournamentRng } from '@sammo-ts/common'; +import { resolveTournamentBattle, TournamentType } from '@sammo-ts/logic'; import { createGamePostgresConnector, createRedisConnector, @@ -14,6 +15,7 @@ import type { TournamentMatchEntry, TournamentState } from './types.js'; const sleepMs = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); const isBattleStage = (stage: number): boolean => stage >= 7 && stage <= 10; +const isPreBattleStage = (stage: number): boolean => stage >= 1 && stage <= 6; const nextStage = (stage: number): number => { switch (stage) { @@ -31,6 +33,116 @@ const nextStage = (stage: number): number => { const resolveNextAt = (state: TournamentState): string => new Date(Date.now() + Math.max(1, state.termSeconds) * 1000).toISOString(); +const resolveBettingCloseAt = (state: TournamentState): string => { + const bettingTermMs = Math.min(state.termSeconds * 60, 3600) * 1000; + return new Date(Date.now() + Math.max(1000, bettingTermMs)).toISOString(); +}; + +const resolveStatValue = (type: TournamentType, entry: { leadership: number; strength: number; intel: number }): number => { + switch (type) { + case TournamentType.LEADERSHIP: + return entry.leadership; + case TournamentType.STRENGTH: + return entry.strength; + case TournamentType.INTEL: + return entry.intel; + case TournamentType.TOTAL: + default: + return entry.leadership + entry.strength + entry.intel; + } +}; + +const pickFinalists = (state: TournamentState, participants: Array<{ id: number; leadership: number; strength: number; intel: number }>): number[] => + participants + .slice() + .sort((lhs, rhs) => { + const lVal = resolveStatValue(state.type, lhs); + const rVal = resolveStatValue(state.type, rhs); + if (lVal !== rVal) { + return rVal - lVal; + } + return lhs.id - rhs.id; + }) + .slice(0, 16) + .map((entry) => entry.id); + +const buildInitialMatches = ( + state: TournamentState, + baseSeed: string, + participantIds: number[] +): TournamentMatchEntry[] => { + const rng = createTournamentRng(baseSeed, { + openYear: state.openYear, + openMonth: state.openMonth, + stage: 5, + phase: state.phase, + matchIndex: 0, + participantIndex: 0, + extraSeed: 'round16', + }); + const shuffled = rng.shuffle(participantIds); + const pairs = shuffled.slice(0, 16); + if (pairs.length < 2 || pairs.length % 2 !== 0) { + throw new Error('대진표를 구성할 참가자가 부족합니다.'); + } + return pairs.reduce((acc, _id, idx) => { + if (idx % 2 !== 0) { + return acc; + } + const attackerId = pairs[idx]; + const defenderId = pairs[idx + 1]; + if (attackerId === undefined || defenderId === undefined) { + return acc; + } + acc.push({ + id: acc.length + 1, + stage: 7, + roundIndex: acc.length, + attackerId, + defenderId, + }); + return acc; + }, []); +}; + +const buildNextMatches = ( + stage: number, + matches: TournamentMatchEntry[] +): TournamentMatchEntry[] => { + const stageMatches = matches.filter((match) => match.stage === stage); + const winners = stageMatches + .map((match) => match.winnerId) + .filter((winner): winner is number => typeof winner === 'number'); + + if (winners.length === 0 || winners.length % 2 !== 0) { + throw new Error('다음 라운드를 만들 수 없습니다.'); + } + + const nextIdBase = matches.reduce((max, entry) => Math.max(max, entry.id), 0) + 1; + const nextStageValue = nextStage(stage); + const result: TournamentMatchEntry[] = []; + + for (let i = 0; i < winners.length; i += 2) { + const attackerId = winners[i]; + const defenderId = winners[i + 1]; + if (attackerId === undefined || defenderId === undefined) { + continue; + } + result.push({ + id: nextIdBase + result.length, + stage: nextStageValue, + roundIndex: i / 2, + attackerId, + defenderId, + }); + } + + if (result.length === 0) { + throw new Error('다음 라운드 대진 생성에 실패했습니다.'); + } + return result; +}; + const applyBattle = async ( store: TournamentStore, state: TournamentState, @@ -42,6 +154,23 @@ const applyBattle = async ( const pending = matches.filter((match) => match.stage === state.stage && !match.winnerId); if (pending.length === 0) { const next = nextStage(state.stage); + if (next === 0) { + const finalMatch = matches.find((match) => match.stage === state.stage && match.winnerId); + const finished: TournamentState = { + ...state, + stage: 0, + phase: 0, + auto: false, + winnerId: finalMatch?.winnerId, + nextAt: resolveNextAt(state), + }; + await store.setState(finished); + return finished; + } + + const nextMatches = buildNextMatches(state.stage, matches); + await store.setMatches(matches.concat(nextMatches)); + const nextState: TournamentState = { ...state, stage: next, @@ -115,6 +244,115 @@ const applyBattle = async ( return nextState; }; +const applyPreBattleStage = async ( + store: TournamentStore, + state: TournamentState, + baseSeed: string +): Promise => { + const participants = await store.getParticipants(); + + if (state.stage === 1) { + if (participants.length < 64) { + const waitingState: TournamentState = { + ...state, + nextAt: resolveNextAt(state), + }; + await store.setState(waitingState); + return waitingState; + } + const nextState: TournamentState = { + ...state, + stage: 2, + phase: 0, + nextAt: resolveNextAt(state), + }; + await store.setState(nextState); + return nextState; + } + + if (state.stage === 2) { + const maxPhase = 27; + const nextPhase = Math.min(maxPhase, state.phase + 1); + const isComplete = nextPhase >= maxPhase; + const nextState: TournamentState = { + ...state, + stage: isComplete ? 3 : state.stage, + phase: isComplete ? 0 : nextPhase, + nextAt: resolveNextAt(state), + }; + await store.setState(nextState); + return nextState; + } + + if (state.stage === 3) { + const nextState: TournamentState = { + ...state, + stage: 4, + phase: 0, + nextAt: resolveNextAt(state), + }; + await store.setState(nextState); + return nextState; + } + + if (state.stage === 4) { + const maxPhase = 5; + const nextPhase = Math.min(maxPhase, state.phase + 1); + const isComplete = nextPhase >= maxPhase; + const nextState: TournamentState = { + ...state, + stage: isComplete ? 5 : state.stage, + phase: isComplete ? 0 : nextPhase, + nextAt: resolveNextAt(state), + }; + await store.setState(nextState); + return nextState; + } + + if (state.stage === 5) { + const matches = await store.getMatches(); + if (matches.length === 0) { + const participantIds = pickFinalists(state, participants); + const initialMatches = buildInitialMatches(state, baseSeed, participantIds); + await store.setMatches(initialMatches); + } + const nextState: TournamentState = { + ...state, + stage: 6, + phase: 0, + bettingCloseAt: resolveBettingCloseAt(state), + nextAt: resolveNextAt(state), + }; + await store.setState(nextState); + return nextState; + } + + if (state.stage === 6) { + const bettingCloseAt = state.bettingCloseAt ?? resolveBettingCloseAt(state); + const bettingCloseMs = new Date(bettingCloseAt).getTime(); + if (Number.isFinite(bettingCloseMs) && bettingCloseMs > Date.now()) { + const waitingState: TournamentState = { + ...state, + bettingCloseAt, + nextAt: bettingCloseAt, + }; + await store.setState(waitingState); + return waitingState; + } + const nextState: TournamentState = { + ...state, + stage: 7, + phase: 0, + bettingCloseAt, + nextAt: resolveNextAt(state), + }; + await store.setState(nextState); + return nextState; + } + + return state; +}; + export const runTournamentWorker = async (): Promise => { const config = resolveGameApiConfigFromEnv(); const postgres = createGamePostgresConnector(resolvePostgresConfigFromEnv({ schema: config.profile })); @@ -139,11 +377,6 @@ export const runTournamentWorker = async (): Promise => { continue; } - if (!isBattleStage(state.stage)) { - await sleepMs(config.tournamentPollMs); - continue; - } - const nextAt = new Date(state.nextAt).getTime(); const now = Date.now(); if (Number.isFinite(nextAt) && nextAt > now) { @@ -154,14 +387,34 @@ export const runTournamentWorker = async (): Promise => { try { const worldState = await postgres.prisma.worldState.findFirst(); const baseSeed = (worldState?.meta as Record | null)?.hiddenSeed ?? 'tournament'; - await applyBattle(store, state, String(baseSeed)); + if (isBattleStage(state.stage)) { + await applyBattle(store, state, String(baseSeed)); + } else if (isPreBattleStage(state.stage)) { + await applyPreBattleStage(store, state, String(baseSeed)); + } } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error'; + const trace = error instanceof Error ? error.stack : undefined; + const now = new Date().toISOString(); + await postgres.prisma.errorLog.create({ + data: { + category: 'TOURNAMENT', + source: 'tournament-worker', + message, + trace, + context: { + stage: state.stage, + phase: state.phase, + bettingId: state.bettingId ?? null, + nextAt: state.nextAt, + }, + }, + }); const nextState: TournamentState = { ...state, auto: false, lastError: message, - lastErrorAt: new Date().toISOString(), + lastErrorAt: now, }; await store.setState(nextState); }