feat: 토너먼트 관련 기능 추가 및 베팅 처리 로직 구현
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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,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`,
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user