From 5846146ff3e56e4c6e4e5726666ff945fc698e64 Mon Sep 17 00:00:00 2001 From: Hide_D Date: Fri, 23 Jan 2026 18:34:50 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=ED=86=A0=EB=84=88=EB=A8=BC=ED=8A=B8=20?= =?UTF-8?q?=EC=9E=90=EB=8F=99=20=EC=8B=9C=EC=9E=91=20=ED=95=B8=EB=93=A4?= =?UTF-8?q?=EB=9F=AC=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EA=B4=80=EB=A0=A8?= =?UTF-8?q?=20=EB=A1=9C=EC=A7=81=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/game-api/src/tournament/worker.ts | 3 +- .../src/turn/tournamentAutoStart.ts | 111 ++++++++++++++++++ app/game-engine/src/turn/turnDaemon.ts | 15 ++- app/game-engine/src/turn/types.ts | 1 + app/game-engine/src/turn/worldLoader.ts | 2 + 5 files changed, 128 insertions(+), 4 deletions(-) create mode 100644 app/game-engine/src/turn/tournamentAutoStart.ts diff --git a/app/game-api/src/tournament/worker.ts b/app/game-api/src/tournament/worker.ts index ed8b5c9..5c7b30d 100644 --- a/app/game-api/src/tournament/worker.ts +++ b/app/game-api/src/tournament/worker.ts @@ -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); diff --git a/app/game-engine/src/turn/tournamentAutoStart.ts b/app/game-engine/src/turn/tournamentAutoStart.ts new file mode 100644 index 0000000..67f78da --- /dev/null +++ b/app/game-engine/src/turn/tournamentAutoStart.ts @@ -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 = (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 | null | undefined; + getTickSeconds: () => number | null | undefined; +}): TurnCalendarHandler => { + const profileName = options.profileName; + const keys = buildTournamentKeys(profileName); + const triggerAutoStart = async (currentYear: number, currentMonth: number): Promise => { + 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(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); + }, + }; +}; \ No newline at end of file diff --git a/app/game-engine/src/turn/turnDaemon.ts b/app/game-engine/src/turn/turnDaemon.ts index 76b5e2d..1718e44 100644 --- a/app/game-engine/src/turn/turnDaemon.ts +++ b/app/game-engine/src/turn/turnDaemon.ts @@ -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 | 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> | null = null; let tournamentRewardFinalizer: Awaited> | null = null; let redisCommandStream: RedisTurnDaemonCommandStream | null = null; - let redisConnector: ReturnType | null = null; let pauseGate: (() => Promise) | undefined; let adminActionConsumer: Awaited> | null = null; const gatewayGate = options.profileName diff --git a/app/game-engine/src/turn/types.ts b/app/game-engine/src/turn/types.ts index b22ce22..499e80f 100644 --- a/app/game-engine/src/turn/types.ts +++ b/app/game-engine/src/turn/types.ts @@ -39,6 +39,7 @@ export interface TurnWorldSnapshot extends Omit< > { scenarioConfig: ScenarioConfig; scenarioMeta?: ScenarioMeta; + worldConfig?: Record; map: MapDefinition; unitSet?: UnitSetDefinition; diplomacy: TurnDiplomacy[]; diff --git a/app/game-engine/src/turn/worldLoader.ts b/app/game-engine/src/turn/worldLoader.ts index c414ea3..24f8b60 100644 --- a/app/game-engine/src/turn/worldLoader.ts +++ b/app/game-engine/src/turn/worldLoader.ts @@ -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,