feat: 토너먼트 관련 기능 추가 및 베팅 처리 로직 구현

This commit is contained in:
2026-01-23 16:58:59 +00:00
parent b6164868b7
commit 293a5035c6
9 changed files with 314 additions and 15 deletions
+2
View File
@@ -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;
+119
View File
@@ -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 };
}),
});
+2
View File
@@ -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`,
});
+9 -1
View File
@@ -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<string | null>;
@@ -43,4 +43,12 @@ export class TournamentStore {
async setMatches(matches: TournamentMatchEntry[]): Promise<void> {
await this.redis.set(this.keys.matchesKey, JSON.stringify(matches));
}
async getBettingEntries(): Promise<TournamentBetEntry[]> {
return safeJsonParse<TournamentBetEntry[]>(await this.redis.get(this.keys.bettingKey)) ?? [];
}
async setBettingEntries(entries: TournamentBetEntry[]): Promise<void> {
await this.redis.set(this.keys.bettingKey, JSON.stringify(entries));
}
}
+7
View File
@@ -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;
}
+67 -3
View File
@@ -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<void> => 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<void> => {
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<void> => {
try {
const worldState = await postgres.prisma.worldState.findFirst();
const baseSeed = (worldState?.meta as Record<string, unknown> | 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';
@@ -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 };
@@ -499,6 +499,54 @@ async function handleTournamentRefund(
};
}
async function handleTournamentBettingPayout(
ctx: CommandHandlerContext,
command: Extract<TurnDaemonCommand, { type: 'tournamentBettingPayout' }>
): Promise<TurnDaemonCommandResult> {
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;
}
+35 -11
View File
@@ -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 }