From e02e6c8fc4887a84c93297db4726eb3b792128c1 Mon Sep 17 00:00:00 2001 From: Hide_D Date: Fri, 23 Jan 2026 17:01:01 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=B2=A0=ED=8C=85=20=EA=B8=B0=EB=8A=A5?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EB=B2=A0=ED=8C=85=20?= =?UTF-8?q?=ED=95=AD=EB=AA=A9=20=EC=A0=80=EC=9E=A5=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/game-api/src/router/tournament/index.ts | 60 +++++++++++++++++++++ app/game-api/src/tournament/store.ts | 7 +++ 2 files changed, 67 insertions(+) diff --git a/app/game-api/src/router/tournament/index.ts b/app/game-api/src/router/tournament/index.ts index 7cad359..420dc4c 100644 --- a/app/game-api/src/router/tournament/index.ts +++ b/app/game-api/src/router/tournament/index.ts @@ -116,4 +116,64 @@ export const tournamentRouter = router({ await store.setBettingEntries(input); return { ok: true, count: input.length }; }), + placeBet: authedProcedure + .input( + z.object({ + targetId: z.number().int().positive(), + amount: z.number().int().positive(), + }) + ) + .mutation(async ({ ctx, input }) => { + const userId = ctx.auth?.user.id; + if (!userId) { + throw new TRPCError({ code: 'UNAUTHORIZED' }); + } + const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name)); + const state = await store.getState(); + if (!state || state.stage !== 6) { + throw new TRPCError({ code: 'BAD_REQUEST', message: '베팅 기간이 아닙니다.' }); + } + const closeAt = state.bettingCloseAt ? new Date(state.bettingCloseAt).getTime() : 0; + if (closeAt && closeAt <= Date.now()) { + throw new TRPCError({ code: 'BAD_REQUEST', message: '베팅이 마감되었습니다.' }); + } + + const matches = await store.getMatches(); + const candidateIds = new Set(); + for (const match of matches) { + if (match.stage === 7) { + candidateIds.add(match.attackerId); + candidateIds.add(match.defenderId); + } + } + if (!candidateIds.has(input.targetId)) { + throw new TRPCError({ code: 'BAD_REQUEST', message: '올바르지 않은 베팅 대상입니다.' }); + } + + const general = await ctx.db.general.findFirst({ + where: { userId }, + select: { id: true, gold: true }, + }); + if (!general) { + throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '장수가 존재하지 않습니다.' }); + } + + const minRemainGold = 500; + if (general.gold - input.amount < minRemainGold) { + throw new TRPCError({ code: 'BAD_REQUEST', message: '소지금이 부족합니다.' }); + } + + await ctx.db.general.update({ + where: { id: general.id }, + data: { gold: general.gold - input.amount }, + }); + + await store.appendBettingEntry({ + generalId: general.id, + targetId: input.targetId, + amount: input.amount, + }); + + return { ok: true }; + }), }); diff --git a/app/game-api/src/tournament/store.ts b/app/game-api/src/tournament/store.ts index 890b9d6..d8a357d 100644 --- a/app/game-api/src/tournament/store.ts +++ b/app/game-api/src/tournament/store.ts @@ -51,4 +51,11 @@ export class TournamentStore { async setBettingEntries(entries: TournamentBetEntry[]): Promise { await this.redis.set(this.keys.bettingKey, JSON.stringify(entries)); } + + async appendBettingEntry(entry: TournamentBetEntry): Promise { + const entries = await this.getBettingEntries(); + entries.push(entry); + await this.setBettingEntries(entries); + return entries; + } }