feat: 토너먼트 자동 시작 핸들러 추가 및 관련 로직 구현
This commit is contained in:
@@ -351,7 +351,6 @@ const sortByRanking = (entries: TournamentParticipantEntry[]): TournamentPartici
|
|||||||
};
|
};
|
||||||
|
|
||||||
const buildFinal16MatchesFromGroups = (
|
const buildFinal16MatchesFromGroups = (
|
||||||
state: TournamentState,
|
|
||||||
participants: TournamentParticipantEntry[]
|
participants: TournamentParticipantEntry[]
|
||||||
): TournamentMatchEntry[] | null => {
|
): TournamentMatchEntry[] | null => {
|
||||||
const groupOrder = [10, 14, 11, 15, 12, 16, 13, 17, 14, 10, 15, 11, 16, 12, 17, 13];
|
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) {
|
if (state.stage === 5) {
|
||||||
const matches = await store.getMatches();
|
const matches = await store.getMatches();
|
||||||
if (matches.length === 0) {
|
if (matches.length === 0) {
|
||||||
const fixedMatches = buildFinal16MatchesFromGroups(state, participants);
|
const fixedMatches = buildFinal16MatchesFromGroups(participants);
|
||||||
const participantIds = fixedMatches
|
const participantIds = fixedMatches
|
||||||
? fixedMatches.flatMap((entry) => [entry.attackerId, entry.defenderId])
|
? fixedMatches.flatMap((entry) => [entry.attackerId, entry.defenderId])
|
||||||
: pickFinalists(state, participants);
|
: 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);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -28,6 +28,7 @@ import { shouldUseAi } from './ai/generalAi.js';
|
|||||||
import { createUnificationHandler } from './unificationHandler.js';
|
import { createUnificationHandler } from './unificationHandler.js';
|
||||||
import { createAuctionFinalizer } from '../auction/finalizer.js';
|
import { createAuctionFinalizer } from '../auction/finalizer.js';
|
||||||
import { createTournamentRewardFinalizer } from '../tournament/finalizer.js';
|
import { createTournamentRewardFinalizer } from '../tournament/finalizer.js';
|
||||||
|
import { createTournamentAutoStartHandler } from './tournamentAutoStart.js';
|
||||||
|
|
||||||
export interface TurnDaemonRuntimeOptions {
|
export interface TurnDaemonRuntimeOptions {
|
||||||
profile: string;
|
profile: string;
|
||||||
@@ -105,6 +106,7 @@ export const createTurnDaemonRuntime = async (options: TurnDaemonRuntimeOptions)
|
|||||||
})
|
})
|
||||||
: await loadTurnCommandProfile());
|
: await loadTurnCommandProfile());
|
||||||
let worldRef: InMemoryTurnWorld | null = null;
|
let worldRef: InMemoryTurnWorld | null = null;
|
||||||
|
let redisConnector: ReturnType<typeof createRedisConnector> | null = null;
|
||||||
const nationTraits = await loadNationTraitModules([...NATION_TRAIT_KEYS], new NationTraitLoader());
|
const nationTraits = await loadNationTraitModules([...NATION_TRAIT_KEYS], new NationTraitLoader());
|
||||||
const nationTraitMap = new Map(nationTraits.map((module) => [module.key, module]));
|
const nationTraitMap = new Map(nationTraits.map((module) => [module.key, module]));
|
||||||
const unification = options.calendarHandler
|
const unification = options.calendarHandler
|
||||||
@@ -119,7 +121,17 @@ export const createTurnDaemonRuntime = async (options: TurnDaemonRuntimeOptions)
|
|||||||
scenarioConfig: snapshot.scenarioConfig,
|
scenarioConfig: snapshot.scenarioConfig,
|
||||||
nationTraits: nationTraitMap,
|
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 = {
|
const worldOptions: InMemoryTurnWorldOptions = {
|
||||||
schedule,
|
schedule,
|
||||||
generalTurnHandler:
|
generalTurnHandler:
|
||||||
@@ -175,7 +187,6 @@ export const createTurnDaemonRuntime = async (options: TurnDaemonRuntimeOptions)
|
|||||||
let auctionFinalizer: Awaited<ReturnType<typeof createAuctionFinalizer>> | null = null;
|
let auctionFinalizer: Awaited<ReturnType<typeof createAuctionFinalizer>> | null = null;
|
||||||
let tournamentRewardFinalizer: Awaited<ReturnType<typeof createTournamentRewardFinalizer>> | null = null;
|
let tournamentRewardFinalizer: Awaited<ReturnType<typeof createTournamentRewardFinalizer>> | null = null;
|
||||||
let redisCommandStream: RedisTurnDaemonCommandStream | null = null;
|
let redisCommandStream: RedisTurnDaemonCommandStream | null = null;
|
||||||
let redisConnector: ReturnType<typeof createRedisConnector> | null = null;
|
|
||||||
let pauseGate: (() => Promise<boolean>) | undefined;
|
let pauseGate: (() => Promise<boolean>) | undefined;
|
||||||
let adminActionConsumer: Awaited<ReturnType<typeof createGatewayAdminActionConsumer>> | null = null;
|
let adminActionConsumer: Awaited<ReturnType<typeof createGatewayAdminActionConsumer>> | null = null;
|
||||||
const gatewayGate = options.profileName
|
const gatewayGate = options.profileName
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export interface TurnWorldSnapshot extends Omit<
|
|||||||
> {
|
> {
|
||||||
scenarioConfig: ScenarioConfig;
|
scenarioConfig: ScenarioConfig;
|
||||||
scenarioMeta?: ScenarioMeta;
|
scenarioMeta?: ScenarioMeta;
|
||||||
|
worldConfig?: Record<string, unknown>;
|
||||||
map: MapDefinition;
|
map: MapDefinition;
|
||||||
unitSet?: UnitSetDefinition;
|
unitSet?: UnitSetDefinition;
|
||||||
diplomacy: TurnDiplomacy[];
|
diplomacy: TurnDiplomacy[];
|
||||||
|
|||||||
@@ -258,6 +258,7 @@ export const loadTurnWorldFromDatabase = async (options: TurnWorldLoaderOptions)
|
|||||||
const diplomacy = diplomacyRows.map(mapDiplomacyRow);
|
const diplomacy = diplomacyRows.map(mapDiplomacyRow);
|
||||||
const troops = troopRows.map(mapTroopRow);
|
const troops = troopRows.map(mapTroopRow);
|
||||||
|
|
||||||
|
const worldConfig = asRecord(worldState.config);
|
||||||
const scenarioConfig = mapScenarioConfig(worldState.config);
|
const scenarioConfig = mapScenarioConfig(worldState.config);
|
||||||
const mapName = scenarioConfig.environment?.mapName ?? 'che';
|
const mapName = scenarioConfig.environment?.mapName ?? 'che';
|
||||||
const map = await loadMapDefinitionByName(mapName, options.mapOptions);
|
const map = await loadMapDefinitionByName(mapName, options.mapOptions);
|
||||||
@@ -304,6 +305,7 @@ export const loadTurnWorldFromDatabase = async (options: TurnWorldLoaderOptions)
|
|||||||
snapshot: {
|
snapshot: {
|
||||||
scenarioConfig,
|
scenarioConfig,
|
||||||
...(scenarioMeta ? { scenarioMeta } : {}),
|
...(scenarioMeta ? { scenarioMeta } : {}),
|
||||||
|
worldConfig,
|
||||||
map,
|
map,
|
||||||
unitSet,
|
unitSet,
|
||||||
nations,
|
nations,
|
||||||
|
|||||||
Reference in New Issue
Block a user