From 293a5035c610bd2ab5361bdba5fe93056b0eac44 Mon Sep 17 00:00:00 2001 From: Hide_D Date: Fri, 23 Jan 2026 16:58:59 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=ED=86=A0=EB=84=88=EB=A8=BC=ED=8A=B8=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?=EB=B0=8F=20=EB=B2=A0=ED=8C=85=20=EC=B2=98=EB=A6=AC=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=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.ts | 2 + app/game-api/src/router/tournament/index.ts | 119 ++++++++++++++++++ app/game-api/src/tournament/keys.ts | 2 + app/game-api/src/tournament/store.ts | 10 +- app/game-api/src/tournament/types.ts | 7 ++ app/game-api/src/tournament/worker.ts | 70 ++++++++++- .../src/lifecycle/redisCommandStream.ts | 23 ++++ .../src/turn/worldCommandHandler.ts | 50 ++++++++ packages/common/src/turnDaemon/types.ts | 46 +++++-- 9 files changed, 314 insertions(+), 15 deletions(-) create mode 100644 app/game-api/src/router/tournament/index.ts diff --git a/app/game-api/src/router.ts b/app/game-api/src/router.ts index 8f62aa8..192f85e 100644 --- a/app/game-api/src/router.ts +++ b/app/game-api/src/router.ts @@ -16,6 +16,7 @@ import { turnDaemonRouter } from './router/turnDaemon/index.js'; import { turnsRouter } from './router/turns/index.js'; import { worldRouter } from './router/world/index.js'; import { auctionRouter } from './router/auction/index.js'; +import { tournamentRouter } from './router/tournament/index.js'; export const appRouter = router({ health: healthRouter, @@ -34,6 +35,7 @@ export const appRouter = router({ npc: npcRouter, turnDaemon: turnDaemonRouter, auction: auctionRouter, + tournament: tournamentRouter, }); export type AppRouter = typeof appRouter; diff --git a/app/game-api/src/router/tournament/index.ts b/app/game-api/src/router/tournament/index.ts new file mode 100644 index 0000000..7cad359 --- /dev/null +++ b/app/game-api/src/router/tournament/index.ts @@ -0,0 +1,119 @@ +import { TRPCError } from '@trpc/server'; +import { z } from 'zod'; + +import type { TournamentType } from '@sammo-ts/logic'; + +import { TournamentStore } from '../../tournament/store.js'; +import { buildTournamentKeys } from '../../tournament/keys.js'; +import { authedProcedure, procedure, router } from '../../trpc.js'; + +const hasAdminRole = (roles: string[], profileName: string): boolean => { + if (roles.includes('superuser') || roles.includes('admin') || roles.includes('admin.superuser')) { + return true; + } + return roles.some((role) => role === 'admin.tournament' || role === `admin.tournament:${profileName}`); +}; + +const adminProcedure = authedProcedure.use(({ ctx, next }) => { + const roles = ctx.auth?.user.roles ?? []; + if (!hasAdminRole(roles, ctx.profile.name)) { + throw new TRPCError({ code: 'FORBIDDEN', message: 'Admin permission is required.' }); + } + return next(); +}); + +const zTournamentState = z.object({ + stage: z.number().int().min(0), + phase: z.number().int().min(0), + type: z.number().int().min(0).max(3), + auto: z.boolean(), + openYear: z.number().int(), + openMonth: z.number().int(), + termSeconds: z.number().int().positive(), + nextAt: z.string().min(1), + bettingId: z.number().int().optional(), + bettingCloseAt: z.string().optional(), + winnerId: z.number().int().optional(), + bettingSettled: z.boolean().optional(), + lastError: z.string().optional(), + lastErrorAt: z.string().optional(), +}); + +const zParticipant = z.object({ + id: z.number().int().positive(), + name: z.string().min(1), + leadership: z.number().int().min(0), + strength: z.number().int().min(0), + intel: z.number().int().min(0), + level: z.number().int().min(0), +}); + +const zMatch = z.object({ + id: z.number().int().positive(), + stage: z.number().int().min(0), + roundIndex: z.number().int().min(0), + attackerId: z.number().int().positive(), + defenderId: z.number().int().positive(), + winnerId: z.number().int().positive().optional(), + log: z.array(z.string()).optional(), +}); + +const zBetEntry = z.object({ + generalId: z.number().int().positive(), + targetId: z.number().int().positive(), + amount: z.number().int().positive(), +}); + +export const tournamentRouter = router({ + getState: procedure.query(async ({ ctx }) => { + const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name)); + return store.getState(); + }), + getSnapshot: procedure.query(async ({ ctx }) => { + const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name)); + const [state, participants, matches, bets] = await Promise.all([ + store.getState(), + store.getParticipants(), + store.getMatches(), + store.getBettingEntries(), + ]); + return { state, participants, matches, bets }; + }), + setState: adminProcedure.input(zTournamentState).mutation(async ({ ctx, input }) => { + const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name)); + await store.setState({ + ...input, + type: input.type as TournamentType, + }); + return { ok: true }; + }), + patchState: adminProcedure.input(zTournamentState.partial()).mutation(async ({ ctx, input }) => { + const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name)); + const current = await store.getState(); + if (!current) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Tournament state not found.' }); + } + const next = { + ...current, + ...input, + type: (input.type !== undefined ? input.type : current.type) as TournamentType, + }; + await store.setState(next); + return { ok: true }; + }), + setParticipants: adminProcedure.input(z.array(zParticipant)).mutation(async ({ ctx, input }) => { + const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name)); + await store.setParticipants(input); + return { ok: true, count: input.length }; + }), + setMatches: adminProcedure.input(z.array(zMatch)).mutation(async ({ ctx, input }) => { + const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name)); + await store.setMatches(input); + return { ok: true, count: input.length }; + }), + setBettingEntries: adminProcedure.input(z.array(zBetEntry)).mutation(async ({ ctx, input }) => { + const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name)); + await store.setBettingEntries(input); + return { ok: true, count: input.length }; + }), +}); diff --git a/app/game-api/src/tournament/keys.ts b/app/game-api/src/tournament/keys.ts index 2caa186..5880fe5 100644 --- a/app/game-api/src/tournament/keys.ts +++ b/app/game-api/src/tournament/keys.ts @@ -2,10 +2,12 @@ export interface TournamentKeys { stateKey: string; participantsKey: string; matchesKey: string; + bettingKey: string; } export const buildTournamentKeys = (profileName: string): TournamentKeys => ({ stateKey: `sammo:${profileName}:tournament:state`, participantsKey: `sammo:${profileName}:tournament:participants`, matchesKey: `sammo:${profileName}:tournament:matches`, + bettingKey: `sammo:${profileName}:tournament:betting`, }); diff --git a/app/game-api/src/tournament/store.ts b/app/game-api/src/tournament/store.ts index 1376de3..890b9d6 100644 --- a/app/game-api/src/tournament/store.ts +++ b/app/game-api/src/tournament/store.ts @@ -1,5 +1,5 @@ import type { TournamentKeys } from './keys.js'; -import type { TournamentMatchEntry, TournamentParticipantEntry, TournamentState } from './types.js'; +import type { TournamentBetEntry, TournamentMatchEntry, TournamentParticipantEntry, TournamentState } from './types.js'; interface RedisClientLike { get(key: string): Promise; @@ -43,4 +43,12 @@ export class TournamentStore { async setMatches(matches: TournamentMatchEntry[]): Promise { await this.redis.set(this.keys.matchesKey, JSON.stringify(matches)); } + + async getBettingEntries(): Promise { + return safeJsonParse(await this.redis.get(this.keys.bettingKey)) ?? []; + } + + async setBettingEntries(entries: TournamentBetEntry[]): Promise { + await this.redis.set(this.keys.bettingKey, JSON.stringify(entries)); + } } diff --git a/app/game-api/src/tournament/types.ts b/app/game-api/src/tournament/types.ts index 660413d..3264c27 100644 --- a/app/game-api/src/tournament/types.ts +++ b/app/game-api/src/tournament/types.ts @@ -12,6 +12,7 @@ export interface TournamentState { bettingId?: number; bettingCloseAt?: string; winnerId?: number; + bettingSettled?: boolean; lastError?: string; lastErrorAt?: string; } @@ -34,3 +35,9 @@ export interface TournamentMatchEntry { winnerId?: number; log?: string[]; } + +export interface TournamentBetEntry { + generalId: number; + targetId: number; + amount: number; +} diff --git a/app/game-api/src/tournament/worker.ts b/app/game-api/src/tournament/worker.ts index 1b0183b..6171586 100644 --- a/app/game-api/src/tournament/worker.ts +++ b/app/game-api/src/tournament/worker.ts @@ -8,9 +8,11 @@ import { } from '@sammo-ts/infra'; import { resolveGameApiConfigFromEnv } from '../config.js'; +import { RedisTurnDaemonTransport } from '../daemon/redisTransport.js'; +import { buildTurnDaemonStreamKeys } from '../daemon/streamKeys.js'; import { buildTournamentKeys } from './keys.js'; import { TournamentStore } from './store.js'; -import type { TournamentMatchEntry, TournamentState } from './types.js'; +import type { TournamentBetEntry, TournamentMatchEntry, TournamentState } from './types.js'; const sleepMs = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); @@ -244,6 +246,28 @@ const applyBattle = async ( return nextState; }; +const buildBettingPayouts = ( + winnerId: number, + entries: TournamentBetEntry[] +): { payouts: Array<{ generalId: number; amount: number }>; total: number; refundAll: boolean } => { + const total = entries.reduce((sum, entry) => sum + entry.amount, 0); + if (total <= 0) { + return { payouts: [], total: 0, refundAll: false }; + } + const winners = entries.filter((entry) => entry.targetId === winnerId); + const winnersTotal = winners.reduce((sum, entry) => sum + entry.amount, 0); + if (winnersTotal <= 0) { + const refunds = entries.map((entry) => ({ generalId: entry.generalId, amount: entry.amount })); + return { payouts: refunds, total, refundAll: true }; + } + const ratio = total / winnersTotal; + const payouts = winners.map((entry) => ({ + generalId: entry.generalId, + amount: Math.round(entry.amount * ratio), + })); + return { payouts, total, refundAll: false }; +}; + const applyPreBattleStage = async ( store: TournamentStore, state: TournamentState, @@ -362,6 +386,10 @@ export const runTournamentWorker = async (): Promise => { await redis.connect(); const store = new TournamentStore(redis.client, buildTournamentKeys(config.profileName)); + const daemonTransport = new RedisTurnDaemonTransport(redis.client, { + keys: buildTurnDaemonStreamKeys(config.profileName), + requestTimeoutMs: config.daemonRequestTimeoutMs, + }); const handleExit = async () => { await redis.disconnect(); @@ -387,10 +415,46 @@ export const runTournamentWorker = async (): Promise => { try { const worldState = await postgres.prisma.worldState.findFirst(); const baseSeed = (worldState?.meta as Record | null)?.hiddenSeed ?? 'tournament'; + let nextState = state; if (isBattleStage(state.stage)) { - await applyBattle(store, state, String(baseSeed)); + nextState = await applyBattle(store, state, String(baseSeed)); } else if (isPreBattleStage(state.stage)) { - await applyPreBattleStage(store, state, String(baseSeed)); + 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', + }); + } + } + } + + const settledState: TournamentState = { + ...nextState, + bettingSettled: true, + }; + 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 1eff2b7..5253f88 100644 --- a/app/game-engine/src/lifecycle/redisCommandStream.ts +++ b/app/game-engine/src/lifecycle/redisCommandStream.ts @@ -233,6 +233,29 @@ const normalizeCommand = (envelope: TurnDaemonCommandEnvelope): TurnDaemonComman refunds, }; } + case 'tournamentBettingPayout': { + if (!Array.isArray(command.payouts)) { + return null; + } + const payouts = command.payouts + .filter((entry) => + entry && typeof entry.generalId === 'number' && typeof entry.amount === 'number' + ) + .map((entry) => ({ + generalId: entry.generalId, + amount: entry.amount, + })); + if (payouts.length === 0) { + return null; + } + return { + type: 'tournamentBettingPayout', + requestId: envelope.requestId, + bettingId: typeof command.bettingId === 'number' ? command.bettingId : undefined, + reason: typeof command.reason === 'string' ? command.reason : undefined, + payouts, + }; + } case 'getStatus': { const requestId = typeof command.requestId === 'string' ? command.requestId : envelope.requestId; return { type: 'getStatus', requestId }; diff --git a/app/game-engine/src/turn/worldCommandHandler.ts b/app/game-engine/src/turn/worldCommandHandler.ts index ce0ac17..0772ea1 100644 --- a/app/game-engine/src/turn/worldCommandHandler.ts +++ b/app/game-engine/src/turn/worldCommandHandler.ts @@ -499,6 +499,54 @@ async function handleTournamentRefund( }; } +async function handleTournamentBettingPayout( + ctx: CommandHandlerContext, + command: Extract +): Promise { + const { world, hooks } = ctx; + if (!command.payouts || command.payouts.length === 0) { + return { + type: 'tournamentBettingPayout', + ok: false, + bettingId: command.bettingId, + reason: '정산 대상이 없습니다.', + }; + } + + let processed = 0; + let missing = 0; + let totalPayout = 0; + + for (const payout of command.payouts) { + if (!payout || typeof payout.generalId !== 'number' || typeof payout.amount !== 'number') { + continue; + } + if (payout.amount <= 0) { + continue; + } + const general = world.getGeneralById(payout.generalId); + if (!general) { + missing += 1; + continue; + } + world.updateGeneral(payout.generalId, { + gold: general.gold + payout.amount, + }); + processed += 1; + totalPayout += payout.amount; + } + + await flushWorld(world, hooks); + return { + type: 'tournamentBettingPayout', + ok: true, + bettingId: command.bettingId, + processed, + missing, + totalPayout, + }; +} + export const createTurnDaemonCommandHandler = (options: { world: InMemoryTurnWorld; hooks?: TurnDaemonHooks; @@ -535,6 +583,8 @@ export const createTurnDaemonCommandHandler = (options: { return handleAppoint(ctx, command); case 'tournamentRefund': return handleTournamentRefund(ctx, command); + case 'tournamentBettingPayout': + return handleTournamentBettingPayout(ctx, command); default: return null; } diff --git a/packages/common/src/turnDaemon/types.ts b/packages/common/src/turnDaemon/types.ts index fde5a1d..d37ba53 100644 --- a/packages/common/src/turnDaemon/types.ts +++ b/packages/common/src/turnDaemon/types.ts @@ -85,17 +85,27 @@ export type TurnDaemonCommand = destGeneralId: number; destCityId: number; officerLevel: number; - } + } | { - type: 'tournamentRefund'; - requestId?: string; - bettingId?: number; - reason?: string; - refunds: Array<{ - generalId: number; - amount: number; - }>; - }; + type: 'tournamentRefund'; + requestId?: string; + bettingId?: number; + reason?: string; + refunds: Array<{ + generalId: number; + amount: number; + }>; + } + | { + type: 'tournamentBettingPayout'; + requestId?: string; + bettingId?: number; + reason?: string; + payouts: Array<{ + generalId: number; + amount: number; + }>; + }; export type TurnDaemonCommandResult = | { @@ -156,7 +166,21 @@ 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; + }; export type TurnDaemonEvent = | { type: 'status'; requestId?: string; status: TurnDaemonStatus }