feat: 토너먼트 자동 시작 핸들러 추가 및 관련 로직 구현

This commit is contained in:
2026-01-23 18:34:50 +00:00
parent 144b8d5705
commit 5846146ff3
5 changed files with 128 additions and 4 deletions
+1 -2
View File
@@ -351,7 +351,6 @@ const sortByRanking = (entries: TournamentParticipantEntry[]): TournamentPartici
};
const buildFinal16MatchesFromGroups = (
state: TournamentState,
participants: TournamentParticipantEntry[]
): TournamentMatchEntry[] | null => {
const groupOrder = [10, 14, 11, 15, 12, 16, 13, 17, 14, 10, 15, 11, 16, 12, 17, 13];
@@ -935,7 +934,7 @@ const applyPreBattleStage = async (
if (state.stage === 5) {
const matches = await store.getMatches();
if (matches.length === 0) {
const fixedMatches = buildFinal16MatchesFromGroups(state, participants);
const fixedMatches = buildFinal16MatchesFromGroups(participants);
const participantIds = fixedMatches
? fixedMatches.flatMap((entry) => [entry.attackerId, entry.defenderId])
: pickFinalists(state, participants);
@@ -0,0 +1,111 @@
import { asRecord } from '@sammo-ts/common';
import type { RedisConnector } from '@sammo-ts/infra';
import type { TurnCalendarHandler } from './inMemoryWorld.js';
interface TournamentState {
stage: number;
phase: number;
type: number;
auto: boolean;
openYear: number;
openMonth: number;
termSeconds: number;
nextAt: string;
bettingId?: number;
bettingCloseAt?: string;
winnerId?: number;
bettingSettled?: boolean;
rewardSettled?: boolean;
participantsLockedAt?: string;
lastError?: string;
lastErrorAt?: string;
}
const safeJsonParse = <T>(raw: string | null): T | null => {
if (!raw) {
return null;
}
try {
return JSON.parse(raw) as T;
} catch {
return null;
}
};
const buildTournamentKeys = (profileName: string) => ({
stateKey: `sammo:${profileName}:tournament:state`,
participantsKey: `sammo:${profileName}:tournament:participants`,
matchesKey: `sammo:${profileName}:tournament:matches`,
bettingKey: `sammo:${profileName}:tournament:betting`,
});
const resolveTermSeconds = (tickSeconds: number): number => {
const turnMinutes = Math.max(1, Math.round(tickSeconds / 60));
const clampedMinutes = Math.min(120, Math.max(5, turnMinutes));
return clampedMinutes * 60;
};
export const createTournamentAutoStartHandler = (options: {
profileName: string;
getRedisClient: () => RedisConnector['client'] | null | undefined;
getWorldConfig: () => Record<string, unknown> | null | undefined;
getTickSeconds: () => number | null | undefined;
}): TurnCalendarHandler => {
const profileName = options.profileName;
const keys = buildTournamentKeys(profileName);
const triggerAutoStart = async (currentYear: number, currentMonth: number): Promise<void> => {
if (currentMonth % 2 !== 0) {
return;
}
const config = asRecord(options.getWorldConfig() ?? {});
if (config.tournamentTrig !== true) {
return;
}
const redis = options.getRedisClient();
if (!redis) {
return;
}
const state = safeJsonParse<TournamentState>(await redis.get(keys.stateKey));
if (state && state.stage > 0) {
return;
}
const now = new Date().toISOString();
const tickSeconds = options.getTickSeconds() ?? 0;
const termSeconds =
state && Number.isFinite(state.termSeconds) && state.termSeconds > 0
? state.termSeconds
: resolveTermSeconds(tickSeconds);
const nextState: TournamentState = {
stage: 1,
phase: 0,
type: state && typeof state.type === 'number' ? state.type : 0,
auto: true,
openYear: currentYear,
openMonth: currentMonth,
termSeconds,
nextAt: now,
bettingId: undefined,
bettingCloseAt: undefined,
winnerId: undefined,
bettingSettled: false,
rewardSettled: false,
participantsLockedAt: undefined,
lastError: undefined,
lastErrorAt: undefined,
};
await redis.set(keys.participantsKey, '[]');
await redis.set(keys.matchesKey, '[]');
await redis.set(keys.bettingKey, '[]');
await redis.set(keys.stateKey, JSON.stringify(nextState));
};
return {
onMonthChanged: (context) => {
void triggerAutoStart(context.currentYear, context.currentMonth);
},
};
};
+13 -2
View File
@@ -28,6 +28,7 @@ import { shouldUseAi } from './ai/generalAi.js';
import { createUnificationHandler } from './unificationHandler.js';
import { createAuctionFinalizer } from '../auction/finalizer.js';
import { createTournamentRewardFinalizer } from '../tournament/finalizer.js';
import { createTournamentAutoStartHandler } from './tournamentAutoStart.js';
export interface TurnDaemonRuntimeOptions {
profile: string;
@@ -105,6 +106,7 @@ export const createTurnDaemonRuntime = async (options: TurnDaemonRuntimeOptions)
})
: await loadTurnCommandProfile());
let worldRef: InMemoryTurnWorld | null = null;
let redisConnector: ReturnType<typeof createRedisConnector> | null = null;
const nationTraits = await loadNationTraitModules([...NATION_TRAIT_KEYS], new NationTraitLoader());
const nationTraitMap = new Map(nationTraits.map((module) => [module.key, module]));
const unification = options.calendarHandler
@@ -119,7 +121,17 @@ export const createTurnDaemonRuntime = async (options: TurnDaemonRuntimeOptions)
scenarioConfig: snapshot.scenarioConfig,
nationTraits: nationTraitMap,
});
const calendarHandler = composeCalendarHandlers(options.calendarHandler ?? unification?.handler, incomeHandler);
const tournamentAutoStartHandler = createTournamentAutoStartHandler({
profileName: options.profileName ?? options.profile,
getRedisClient: () => redisConnector?.client,
getWorldConfig: () => snapshot.worldConfig ?? null,
getTickSeconds: () => worldRef?.getState().tickSeconds ?? null,
});
const calendarHandler = composeCalendarHandlers(
options.calendarHandler ?? unification?.handler,
incomeHandler,
tournamentAutoStartHandler
);
const worldOptions: InMemoryTurnWorldOptions = {
schedule,
generalTurnHandler:
@@ -175,7 +187,6 @@ export const createTurnDaemonRuntime = async (options: TurnDaemonRuntimeOptions)
let auctionFinalizer: Awaited<ReturnType<typeof createAuctionFinalizer>> | null = null;
let tournamentRewardFinalizer: Awaited<ReturnType<typeof createTournamentRewardFinalizer>> | null = null;
let redisCommandStream: RedisTurnDaemonCommandStream | null = null;
let redisConnector: ReturnType<typeof createRedisConnector> | null = null;
let pauseGate: (() => Promise<boolean>) | undefined;
let adminActionConsumer: Awaited<ReturnType<typeof createGatewayAdminActionConsumer>> | null = null;
const gatewayGate = options.profileName
+1
View File
@@ -39,6 +39,7 @@ export interface TurnWorldSnapshot extends Omit<
> {
scenarioConfig: ScenarioConfig;
scenarioMeta?: ScenarioMeta;
worldConfig?: Record<string, unknown>;
map: MapDefinition;
unitSet?: UnitSetDefinition;
diplomacy: TurnDiplomacy[];
+2
View File
@@ -258,6 +258,7 @@ export const loadTurnWorldFromDatabase = async (options: TurnWorldLoaderOptions)
const diplomacy = diplomacyRows.map(mapDiplomacyRow);
const troops = troopRows.map(mapTroopRow);
const worldConfig = asRecord(worldState.config);
const scenarioConfig = mapScenarioConfig(worldState.config);
const mapName = scenarioConfig.environment?.mapName ?? 'che';
const map = await loadMapDefinitionByName(mapName, options.mapOptions);
@@ -304,6 +305,7 @@ export const loadTurnWorldFromDatabase = async (options: TurnWorldLoaderOptions)
snapshot: {
scenarioConfig,
...(scenarioMeta ? { scenarioMeta } : {}),
worldConfig,
map,
unitSet,
nations,