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
@@ -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';