From c4a5361fc628c15bde13890fa33554b3278f15a3 Mon Sep 17 00:00:00 2001 From: Hide_D Date: Thu, 29 Jan 2026 18:33:56 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=ED=86=A0=EB=84=88=EB=A8=BC=ED=8A=B8=20?= =?UTF-8?q?=EB=A9=94=ED=83=80=20=EC=A1=B0=EC=A0=95=20=EB=B0=8F=20=EA=B2=B0?= =?UTF-8?q?=EA=B3=BC=20=EC=B2=98=EB=A6=AC=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/game-api/src/router/tournament/index.ts | 11 + app/game-api/src/tournament/worker.ts | 58 +++++- app/game-api/src/tournament/workerHelpers.ts | 29 ++- app/game-engine/src/turn/commandRegistry.ts | 39 ++++ .../src/turn/worldCommandHandler.ts | 190 ++++++++++++++++++ packages/common/src/turnDaemon/types.ts | 45 +++++ 6 files changed, 365 insertions(+), 7 deletions(-) diff --git a/app/game-api/src/router/tournament/index.ts b/app/game-api/src/router/tournament/index.ts index 5b9c981..9c34702 100644 --- a/app/game-api/src/router/tournament/index.ts +++ b/app/game-api/src/router/tournament/index.ts @@ -421,6 +421,17 @@ export const tournamentRouter = router({ throw new TRPCError({ code: 'BAD_REQUEST', message: adjustResult.reason }); } + await ctx.turnDaemon.sendCommand({ + type: 'adjustGeneralMeta', + reason: 'tournamentBet', + adjustments: [ + { + generalId: general.id, + metaDelta: { rank_betgold: input.amount }, + }, + ], + }); + await store.appendBettingEntry({ generalId: general.id, targetId: input.targetId, diff --git a/app/game-api/src/tournament/worker.ts b/app/game-api/src/tournament/worker.ts index 02d0be4..82646e6 100644 --- a/app/game-api/src/tournament/worker.ts +++ b/app/game-api/src/tournament/worker.ts @@ -33,13 +33,15 @@ import { seedNpcBets, sleepMs, sortByRanking, + type TournamentMatchOutcome, type TournamentPrismaClient, } from './workerHelpers.js'; export const applyBattle = async ( store: TournamentStore, state: TournamentState, - baseSeed: string + baseSeed: string, + daemonTransport?: TurnDaemonTransport ): Promise => { const matches = await store.getMatches(); const participants = await store.getParticipants(); @@ -133,6 +135,16 @@ export const applyBattle = async ( const nextMatches = matches.map((entry) => (entry.id === target.id ? updatedMatch : entry)); await store.setMatches(nextMatches); + if (daemonTransport && attacker.id > 0 && defender.id > 0) { + await daemonTransport.sendCommand({ + type: 'tournamentMatchResult', + tournamentType: state.type, + attackerId: attacker.id, + defenderId: defender.id, + result: result.draw ? 'draw' : result.winnerId === attacker.id ? 'attacker' : 'defender', + }); + } + const nextPhase = state.phase + 1; const nextState: TournamentState = { ...state, @@ -192,6 +204,7 @@ export const applyPreBattleStage = async ( let updated = participants.some((entry) => entry.groupId === undefined) ? assignGroupSlots(participants, 8, 8, 0) : participants; + const outcomes: TournamentMatchOutcome[] = []; for (let groupId = 0; groupId < 8; groupId += 1) { const groupEntries = updated.filter((entry) => entry.groupId === groupId); const attacker = groupEntries.find((entry) => entry.groupNo === pair[0]); @@ -199,10 +212,28 @@ export const applyPreBattleStage = async ( if (!attacker || !defender) { continue; } - updated = applyGroupMatch(updated, attacker, defender, state, baseSeed, groupId); + const result = applyGroupMatch(updated, attacker, defender, state, baseSeed, groupId); + updated = result.participants; + outcomes.push(result.outcome); } await store.setParticipants(updated); + if (outcomes.length > 0) { + await Promise.all( + outcomes + .filter((outcome) => outcome.attackerId > 0 && outcome.defenderId > 0) + .map((outcome) => + daemonTransport.sendCommand({ + type: 'tournamentMatchResult', + tournamentType: state.type, + attackerId: outcome.attackerId, + defenderId: outcome.defenderId, + result: outcome.result, + }) + ) + ); + } + const maxPhase = 55; const isComplete = state.phase >= maxPhase; if (isComplete) { @@ -310,6 +341,7 @@ export const applyPreBattleStage = async ( throw new Error('본선 매치 구성을 찾을 수 없습니다.'); } let updated = participants; + const outcomes: TournamentMatchOutcome[] = []; for (let groupId = 10; groupId < 18; groupId += 1) { const groupEntries = updated.filter((entry) => entry.groupId === groupId); const attacker = groupEntries.find((entry) => entry.groupNo === pair[0]); @@ -317,10 +349,28 @@ export const applyPreBattleStage = async ( if (!attacker || !defender) { continue; } - updated = applyGroupMatch(updated, attacker, defender, state, baseSeed, groupId); + const result = applyGroupMatch(updated, attacker, defender, state, baseSeed, groupId); + updated = result.participants; + outcomes.push(result.outcome); } await store.setParticipants(updated); + if (outcomes.length > 0) { + await Promise.all( + outcomes + .filter((outcome) => outcome.attackerId > 0 && outcome.defenderId > 0) + .map((outcome) => + daemonTransport.sendCommand({ + type: 'tournamentMatchResult', + tournamentType: state.type, + attackerId: outcome.attackerId, + defenderId: outcome.defenderId, + result: outcome.result, + }) + ) + ); + } + const maxPhase = 5; if (state.phase >= maxPhase) { for (let groupId = 10; groupId < 18; groupId += 1) { @@ -509,7 +559,7 @@ export const runTournamentWorker = async (): Promise => { const baseSeed = (worldState?.meta as Record | null)?.hiddenSeed ?? 'tournament'; let nextState = state; if (isBattleStage(state.stage)) { - nextState = await applyBattle(store, state, String(baseSeed)); + nextState = await applyBattle(store, state, String(baseSeed), daemonTransport); } else if (isPreBattleStage(state.stage)) { nextState = await applyPreBattleStage( store, diff --git a/app/game-api/src/tournament/workerHelpers.ts b/app/game-api/src/tournament/workerHelpers.ts index 817aa97..4bfe3cf 100644 --- a/app/game-api/src/tournament/workerHelpers.ts +++ b/app/game-api/src/tournament/workerHelpers.ts @@ -282,6 +282,12 @@ export const fillParticipants = async (options: { return result; }; +export type TournamentMatchOutcome = { + attackerId: number; + defenderId: number; + result: 'attacker' | 'defender' | 'draw'; +}; + export const applyGroupMatch = ( participants: TournamentParticipantEntry[], attacker: TournamentParticipantEntry, @@ -289,7 +295,7 @@ export const applyGroupMatch = ( state: TournamentState, baseSeed: string, matchIndex: number -): TournamentParticipantEntry[] => { +): { participants: TournamentParticipantEntry[]; outcome: TournamentMatchOutcome } => { const result = resolveTournamentBattle({ type: state.type, battleType: 0, @@ -325,7 +331,14 @@ export const applyGroupMatch = ( const glDelta = Math.round((result.totalDamage.defender - result.totalDamage.attacker) / 50); - return participants.map((entry) => { + const outcome: TournamentMatchOutcome = { + attackerId: attacker.id, + defenderId: defender.id, + result: result.draw ? 'draw' : result.winnerId === attacker.id ? 'attacker' : 'defender', + }; + + return { + participants: participants.map((entry) => { if (entry.id !== attacker.id && entry.id !== defender.id) { return entry; } @@ -348,7 +361,9 @@ export const applyGroupMatch = ( next.lose += 1; next.gl -= glDelta; return next; - }); + }), + outcome, + }; }; export const sortByRanking = (entries: TournamentParticipantEntry[]): TournamentParticipantEntry[] => @@ -632,5 +647,13 @@ export const seedNpcBets = async (options: { goldDelta: -betGold, })), }); + await daemonTransport.sendCommand({ + type: 'adjustGeneralMeta', + reason: 'tournamentNpcBet', + adjustments: npcBetList.map((npc) => ({ + generalId: npc.id as number, + metaDelta: { rank_betgold: betGold }, + })), + }); await store.setBettingEntries(entries); }; diff --git a/app/game-engine/src/turn/commandRegistry.ts b/app/game-engine/src/turn/commandRegistry.ts index b36e263..8440b2d 100644 --- a/app/game-engine/src/turn/commandRegistry.ts +++ b/app/game-engine/src/turn/commandRegistry.ts @@ -165,6 +165,27 @@ const zAdjustGeneralResources = z .min(1), }); +const zAdjustGeneralMeta = z.object({ + type: z.literal('adjustGeneralMeta'), + reason: z.string().optional(), + adjustments: z + .array( + z.object({ + generalId: zFiniteNumber, + metaDelta: z.record(z.string(), zFiniteNumber), + }) + ) + .min(1), +}); + +const zTournamentMatchResult = z.object({ + type: z.literal('tournamentMatchResult'), + tournamentType: zFiniteNumber, + attackerId: zFiniteNumber, + defenderId: zFiniteNumber, + result: z.enum(['attacker', 'defender', 'draw']), +}); + const zPatchGeneral = z.object({ type: z.literal('patchGeneral'), generalId: zFiniteNumber, @@ -353,6 +374,22 @@ const normalizeAdjustGeneralResources: CommandNormalizer<'adjustGeneralResources return { ...command, requestId: envelope.requestId }; }; +const normalizeAdjustGeneralMeta: CommandNormalizer<'adjustGeneralMeta'> = (envelope) => { + const command = parseWith(zAdjustGeneralMeta, envelope.command); + if (!command) { + return null; + } + return { ...command, requestId: envelope.requestId }; +}; + +const normalizeTournamentMatchResult: CommandNormalizer<'tournamentMatchResult'> = (envelope) => { + const command = parseWith(zTournamentMatchResult, envelope.command); + if (!command) { + return null; + } + return { ...command, requestId: envelope.requestId }; +}; + const normalizePatchGeneral: CommandNormalizer<'patchGeneral'> = (envelope) => { const command = parseWith(zPatchGeneral, envelope.command); if (!command) { @@ -396,6 +433,8 @@ const normalizers: CommandNormalizerMap = { tournamentReward: normalizeTournamentReward, setNationMeta: normalizeSetNationMeta, adjustGeneralResources: normalizeAdjustGeneralResources, + adjustGeneralMeta: normalizeAdjustGeneralMeta, + tournamentMatchResult: normalizeTournamentMatchResult, patchGeneral: normalizePatchGeneral, getStatus: normalizeGetStatus, run: normalizeRun, diff --git a/app/game-engine/src/turn/worldCommandHandler.ts b/app/game-engine/src/turn/worldCommandHandler.ts index c4e54d9..f7de0cb 100644 --- a/app/game-engine/src/turn/worldCommandHandler.ts +++ b/app/game-engine/src/turn/worldCommandHandler.ts @@ -150,6 +150,172 @@ async function handleAdjustGeneralResources( }; } +async function handleAdjustGeneralMeta( + ctx: CommandHandlerContext, + command: Extract +): Promise { + const { world, hooks } = ctx; + if (!command.adjustments || command.adjustments.length === 0) { + return { + type: 'adjustGeneralMeta', + ok: false, + reason: '메타 조정 대상이 없습니다.', + }; + } + + let processed = 0; + let missing = 0; + + for (const adjustment of command.adjustments) { + if (!adjustment || typeof adjustment.generalId !== 'number') { + continue; + } + const general = world.getGeneralById(adjustment.generalId); + if (!general) { + missing += 1; + continue; + } + const nextMeta = { ...general.meta } as TurnGeneral['meta']; + for (const [key, delta] of Object.entries(adjustment.metaDelta ?? {})) { + if (typeof delta !== 'number' || !Number.isFinite(delta)) { + continue; + } + const current = typeof nextMeta[key] === 'number' ? Number(nextMeta[key]) : 0; + nextMeta[key] = current + delta; + } + world.updateGeneral(adjustment.generalId, { meta: nextMeta }); + processed += 1; + } + + await flushWorld(world, hooks); + return { + type: 'adjustGeneralMeta', + ok: true, + processed, + missing, + }; +} + +async function handleTournamentMatchResult( + ctx: CommandHandlerContext, + command: Extract +): Promise { + const { world, hooks } = ctx; + const resolvePrefix = (type: number): string => { + switch (type) { + case 1: + return 'tl'; + case 2: + return 'ts'; + case 3: + return 'ti'; + case 0: + default: + return 'tt'; + } + }; + + const prefix = resolvePrefix(command.tournamentType); + const attacker = command.attackerId > 0 ? world.getGeneralById(command.attackerId) : null; + const defender = command.defenderId > 0 ? world.getGeneralById(command.defenderId) : null; + if (!attacker && !defender) { + return { + type: 'tournamentMatchResult', + ok: false, + tournamentType: command.tournamentType, + attackerId: command.attackerId, + defenderId: command.defenderId, + result: command.result, + reason: '장수 정보를 찾을 수 없습니다.', + }; + } + + const rankKey = (suffix: string): string => `rank_${prefix}${suffix}`; + const getRankNumber = (general: TurnGeneral | null, key: string): number => + general && typeof general.meta[key] === 'number' ? Number(general.meta[key]) : 0; + + const attackerG = getRankNumber(attacker, rankKey('g')); + const defenderG = getRankNumber(defender, rankKey('g')); + + let attackerGDelta = 0; + let defenderGDelta = 0; + let attackerW = 0; + let attackerD = 0; + let attackerL = 0; + let defenderW = 0; + let defenderD = 0; + let defenderL = 0; + + if (command.result === 'attacker') { + attackerW = 1; + defenderL = 1; + if (attackerG > defenderG) { + attackerGDelta = 1; + defenderGDelta = 0; + } else if (attackerG === defenderG) { + attackerGDelta = 2; + defenderGDelta = -1; + } else { + attackerGDelta = 3; + defenderGDelta = -2; + } + } else if (command.result === 'defender') { + attackerL = 1; + defenderW = 1; + if (defenderG > attackerG) { + defenderGDelta = 1; + attackerGDelta = 0; + } else if (attackerG === defenderG) { + defenderGDelta = 2; + attackerGDelta = -1; + } else { + defenderGDelta = 3; + attackerGDelta = -2; + } + } else { + attackerD = 1; + defenderD = 1; + if (attackerG > defenderG) { + attackerGDelta = 1; + defenderGDelta = -1; + } else if (attackerG === defenderG) { + attackerGDelta = 0; + defenderGDelta = 0; + } else { + attackerGDelta = -1; + defenderGDelta = 1; + } + } + + if (attacker) { + const nextMeta = { ...attacker.meta } as TurnGeneral['meta']; + nextMeta[rankKey('w')] = getRankNumber(attacker, rankKey('w')) + attackerW; + nextMeta[rankKey('d')] = getRankNumber(attacker, rankKey('d')) + attackerD; + nextMeta[rankKey('l')] = getRankNumber(attacker, rankKey('l')) + attackerL; + nextMeta[rankKey('g')] = attackerG + attackerGDelta; + world.updateGeneral(attacker.id, { meta: nextMeta }); + } + + if (defender) { + const nextMeta = { ...defender.meta } as TurnGeneral['meta']; + nextMeta[rankKey('w')] = getRankNumber(defender, rankKey('w')) + defenderW; + nextMeta[rankKey('d')] = getRankNumber(defender, rankKey('d')) + defenderD; + nextMeta[rankKey('l')] = getRankNumber(defender, rankKey('l')) + defenderL; + nextMeta[rankKey('g')] = defenderG + defenderGDelta; + world.updateGeneral(defender.id, { meta: nextMeta }); + } + + await flushWorld(world, hooks); + return { + type: 'tournamentMatchResult', + ok: true, + tournamentType: command.tournamentType, + attackerId: command.attackerId, + defenderId: command.defenderId, + result: command.result, + }; +} + async function handlePatchGeneral( ctx: CommandHandlerContext, command: Extract @@ -691,6 +857,7 @@ async function handleTournamentBettingPayout( let processed = 0; let missing = 0; let totalPayout = 0; + const metaDeltas = new Map(); for (const payout of command.payouts) { if (!payout || typeof payout.generalId !== 'number' || typeof payout.amount !== 'number') { @@ -709,6 +876,26 @@ async function handleTournamentBettingPayout( }); processed += 1; totalPayout += payout.amount; + const currentDelta = metaDeltas.get(payout.generalId) ?? { betwin: 0, betwingold: 0 }; + metaDeltas.set(payout.generalId, { + betwin: currentDelta.betwin + 1, + betwingold: currentDelta.betwingold + payout.amount, + }); + } + + for (const [generalId, delta] of metaDeltas) { + const general = world.getGeneralById(generalId); + if (!general) { + continue; + } + const nextMeta = { ...general.meta } as TurnGeneral['meta']; + const betwinKey = 'rank_betwin'; + const betwingoldKey = 'rank_betwingold'; + const currentBetwin = typeof nextMeta[betwinKey] === 'number' ? Number(nextMeta[betwinKey]) : 0; + const currentBetwingold = typeof nextMeta[betwingoldKey] === 'number' ? Number(nextMeta[betwingoldKey]) : 0; + nextMeta[betwinKey] = currentBetwin + delta.betwin; + nextMeta[betwingoldKey] = currentBetwingold + delta.betwingold; + world.updateGeneral(generalId, { meta: nextMeta }); } await flushWorld(world, hooks); @@ -774,6 +961,9 @@ export const createTurnDaemonCommandHandler = (options: { tournamentReward: (command) => handleTournamentReward(ctx, command as Extract), setNationMeta: (command) => handleSetNationMeta(ctx, command as Extract), adjustGeneralResources: (command) => handleAdjustGeneralResources(ctx, command as Extract), + adjustGeneralMeta: (command) => handleAdjustGeneralMeta(ctx, command as Extract), + tournamentMatchResult: (command) => + handleTournamentMatchResult(ctx, command as Extract), patchGeneral: (command) => handlePatchGeneral(ctx, command as Extract), }; diff --git a/packages/common/src/turnDaemon/types.ts b/packages/common/src/turnDaemon/types.ts index 7f23d46..531252f 100644 --- a/packages/common/src/turnDaemon/types.ts +++ b/packages/common/src/turnDaemon/types.ts @@ -133,6 +133,23 @@ export type TurnDaemonCommand = riceDelta?: number; }>; } + | { + type: 'adjustGeneralMeta'; + requestId?: string; + reason?: string; + adjustments: Array<{ + generalId: number; + metaDelta: Record; + }>; + } + | { + type: 'tournamentMatchResult'; + requestId?: string; + tournamentType: number; + attackerId: number; + defenderId: number; + result: 'attacker' | 'defender' | 'draw'; + } | { type: 'patchGeneral'; requestId?: string; @@ -274,6 +291,34 @@ export type TurnDaemonCommandResult = ok: false; reason: string; } + | { + type: 'adjustGeneralMeta'; + ok: true; + processed: number; + missing: number; + } + | { + type: 'adjustGeneralMeta'; + ok: false; + reason: string; + } + | { + type: 'tournamentMatchResult'; + ok: true; + tournamentType: number; + attackerId: number; + defenderId: number; + result: 'attacker' | 'defender' | 'draw'; + } + | { + type: 'tournamentMatchResult'; + ok: false; + tournamentType: number; + attackerId: number; + defenderId: number; + result: 'attacker' | 'defender' | 'draw'; + reason: string; + } | { type: 'patchGeneral'; ok: true;