local ____lualib = require("lualib_bundle") local __TS__Class = ____lualib.__TS__Class local Map = ____lualib.Map local __TS__New = ____lualib.__TS__New local Set = ____lualib.Set local __TS__ArrayMap = ____lualib.__TS__ArrayMap local __TS__ArraySort = ____lualib.__TS__ArraySort local __TS__Iterator = ____lualib.__TS__Iterator local __TS__ArrayIsArray = ____lualib.__TS__ArrayIsArray local __TS__Number = ____lualib.__TS__Number local __TS__ArrayFrom = ____lualib.__TS__ArrayFrom local ____exports = {} local ____server_config = require("server_config") local SERVER_CONFIG = ____server_config.SERVER_CONFIG local ____api_helper = require("api_helper") local encodeApiBody = ____api_helper.encodeApiBody local setApiHeadersLong = ____api_helper.setApiHeadersLong local ____difficulty_manager = require("difficulty_manager") local Difficulty = ____difficulty_manager.Difficulty local ____player_connection_state = require("utils.player_connection_state") local DOTA_CONNECTION_STATE = ____player_connection_state.DOTA_CONNECTION_STATE local isConnectionStateActivelyConnected = ____player_connection_state.isConnectionStateActivelyConnected local isConnectionStateEffectivelyInGame = ____player_connection_state.isConnectionStateEffectivelyInGame local ____real_lobby_player = require("utils.real_lobby_player") local collectStatsEligiblePlayerIds = ____real_lobby_player.collectStatsEligiblePlayerIds local isRealLobbyPlayer = ____real_lobby_player.isRealLobbyPlayer local ____match_end_combat_stats = require("match_end_combat_stats") local MatchEndCombatStats = ____match_end_combat_stats.MatchEndCombatStats local function rawPrintFn(____, ...) _G:print(...) end local ENABLE_VERBOSE_GAME_STATS_LOGS = false local ____print = ENABLE_VERBOSE_GAME_STATS_LOGS and rawPrintFn or (function(____, ...) return nil end) --- Секунд подряд «никого в игре» до срабатывания «все вышли» (антидребезг). local ALL_LEFT_DEBOUNCE_SECONDS = 8 --- Игровое время после DISCONNECTED до поражения. local DISCONNECT_TIMEOUT_SECONDS = 90 --- Игровое время после ABANDONED/FAILED до поражения. local ABANDON_DISCONNECT_TIMEOUT_SECONDS = 15 --- Интервал успешного heartbeat во время матча. local HEARTBEAT_INTERVAL_SECONDS = 60 --- Повторная отправка, если сервер не принял сигнал. local HEARTBEAT_RETRY_SECONDS = 5 local ALLOW_STATS_WITH_CHEATS = false local ALLOW_STATS_IN_TOOLS_MODE = false --- Релиз: при читах/sv_cheats — не начислять BP/осколки за матч (см. shouldBlockMatchEndRewards). local ALLOW_MATCH_END_REWARDS_WITH_CHEATS = false --- Релиз: Workshop Tools — без наград конца матча (локальная отладка без выдачи на аккаунт). local ALLOW_MATCH_END_REWARDS_IN_TOOLS = false ____exports.GameStatsTracker = __TS__Class() local GameStatsTracker = ____exports.GameStatsTracker GameStatsTracker.name = "GameStatsTracker" GameStatsTracker.____file_path = "scripts/vscripts/game_stats_tracker.lua" function GameStatsTracker.prototype.____constructor(self) self.gameStartTime = 0 self.isGameStarted = false self.isGameEnded = false self.playerGameData = __TS__New(Map) self.lastKnownPlayers = __TS__New(Set) self.playerDisconnectTime = __TS__New(Map) self.pendingPlayers = __TS__New(Set) self.registerStartInFlight = __TS__New(Set) self.queuedPlayersForMatchBootstrap = __TS__New(Set) self.isStatsDisabledForCurrentMatch = false self.statsDisabledReason = "" self.sessionParticipantsOrdered = {} self.sessionParticipantIdSet = __TS__New(Set) self.sessionParticipantsSteamOrdered = {} self:setupListeners() end function GameStatsTracker.getInstance(self) if not ____exports.GameStatsTracker.instance then ____exports.GameStatsTracker.instance = __TS__New(____exports.GameStatsTracker) end return ____exports.GameStatsTracker.instance end function GameStatsTracker.prototype.isPlayerEffectivelyInGame(self, playerId) if not isRealLobbyPlayer(nil, playerId) then return false end local hero = PlayerResource:GetSelectedHeroEntity(playerId) if not hero then return false end local cs = PlayerResource:GetConnectionState(playerId) return isConnectionStateEffectivelyInGame(nil, cs) end function GameStatsTracker.prototype.isPlayerActivelyConnected(self, playerId) if not isRealLobbyPlayer(nil, playerId) then return false end local hero = PlayerResource:GetSelectedHeroEntity(playerId) if not hero then return false end local cs = PlayerResource:GetConnectionState(playerId) return isConnectionStateActivelyConnected(nil, cs) end function GameStatsTracker.prototype.collectEffectivePlayersInGame(self) local set = __TS__New(Set) do local i = 0 while i < DOTA_MAX_PLAYERS do do local playerId = i if not PlayerResource:IsValidPlayerID(playerId) then goto __continue14 end if self:isPlayerEffectivelyInGame(playerId) then set:add(playerId) end end ::__continue14:: i = i + 1 end end return set end function GameStatsTracker.prototype.collectActivelyConnectedPlayers(self) local set = __TS__New(Set) do local i = 0 while i < DOTA_MAX_PLAYERS do do local playerId = i if not PlayerResource:IsValidPlayerID(playerId) then goto __continue18 end if self:isPlayerActivelyConnected(playerId) then set:add(playerId) end end ::__continue18:: i = i + 1 end end return set end function GameStatsTracker.prototype.getDisconnectTimeoutForPlayer(self, playerId) if not PlayerResource:IsValidPlayer(playerId) then return ABANDON_DISCONNECT_TIMEOUT_SECONDS end local cs = PlayerResource:GetConnectionState(playerId) if cs == DOTA_CONNECTION_STATE.ABANDONED or cs == DOTA_CONNECTION_STATE.FAILED then return ABANDON_DISCONNECT_TIMEOUT_SECONDS end return DISCONNECT_TIMEOUT_SECONDS end function GameStatsTracker.prototype.setupListeners(self) ListenToGameEvent( "game_rules_state_change", function() return self:onGameStateChange() end, self ) ListenToGameEvent( "npc_spawned", function(____, event) return self:onNpcSpawned(event) end, self ) end function GameStatsTracker.prototype.onGameStateChange(self) local gameState = GameRules:State_Get() if gameState == 5 and not self.isGameStarted then self:onGameStart() end end function GameStatsTracker.prototype.onNpcSpawned(self, event) local unit = EntIndexToHScript(event.entindex) if not unit or not unit:IsRealHero() then return end local playerId = unit:GetPlayerOwnerID() if playerId == -1 or not PlayerResource:IsValidPlayer(playerId) or PlayerResource:IsFakeClient(playerId) then return end if self.playerGameData:has(playerId) then return end if self.registerStartInFlight:has(playerId) then return end local hero = unit local playerName = PlayerResource:GetPlayerName(playerId) local gameState = GameRules:State_Get() ____print( nil, (((((("[GameStatsTracker] 🔍 npc_spawned: герой " .. hero:GetUnitName()) .. ", игрок ") .. playerName) .. ", gameState: ") .. tostring(gameState)) .. ", isGameStarted: ") .. tostring(self.isGameStarted) ) if self.isGameEnded then ____print(nil, "[GameStatsTracker] ⚠️ npc_spawned: игра уже завершена, пропускаем") return end local eligibleNow = collectStatsEligiblePlayerIds(nil) local inEligibleRoster = false for ____, id in ipairs(eligibleNow) do if id == playerId then inEligibleRoster = true break end end if not inEligibleRoster then ____print( nil, ("[GameStatsTracker] ⏭️ npc_spawned: слот " .. tostring(playerId)) .. " не в составе лобби для статистики, пропуск" ) return end if self.isGameStarted then if not self.sessionParticipantIdSet:has(playerId) then ____print(nil, ("[GameStatsTracker] ⏭️ npc_spawned: игрок " .. playerName) .. " вне снимка сессии, пропуск") return end ____print( nil, ((((("[GameStatsTracker] 🦸 Герой появился! Регистрируем игрока " .. playerName) .. " (герой: ") .. hero:GetUnitName()) .. ", gameState: ") .. tostring(gameState)) .. ")" ) self:registerPlayerGame(playerId, hero) return end ____print( nil, ("[GameStatsTracker] ⏳ npc_spawned: игра еще не началась (state: " .. tostring(gameState)) .. "), сохраняем для отложенной регистрации" ) self.pendingPlayers:add(playerId) end function GameStatsTracker.prototype.onGameStart(self) Difficulty:ensureHeroSelectionResolvedForMatchStart() self.isGameStarted = true self.isGameEnded = false self.gameStartTime = GameRules:GetGameTime() self.playerGameData:clear() self.lastKnownPlayers:clear() self.playerDisconnectTime:clear() self.registerStartInFlight:clear() self.sharedMatchId = nil local gameTimeMs = math.floor(GameRules:GetGameTime() * 1000) local rHigh = RandomInt(1, 2147483647) local rLow = RandomInt(1, 2147483647) self.currentSessionId = (((("session_" .. tostring(gameTimeMs)) .. "_") .. tostring(rHigh)) .. "_") .. tostring(rLow) self.sessionParticipantsOrdered = collectStatsEligiblePlayerIds(nil) self.sessionParticipantIdSet = __TS__New(Set, self.sessionParticipantsOrdered) self.sessionParticipantsSteamOrdered = __TS__ArrayMap( self.sessionParticipantsOrdered, function(____, id) return tostring(PlayerResource:GetSteamAccountID(id)) end ) __TS__ArraySort( self.sessionParticipantsSteamOrdered, function(____, a, b) return (tonumber(a) or 0) - (tonumber(b) or 0) end ) rawPrintFn( nil, (("[GameStatsTracker] Участники сессии (" .. tostring(#self.sessionParticipantsOrdered)) .. "): ") .. table.concat(self.sessionParticipantsSteamOrdered, ",") ) self.matchBootstrapPlayerId = nil self.queuedPlayersForMatchBootstrap:clear() self.allLeftDebounceSinceGameTime = nil self.isStatsDisabledForCurrentMatch = self:shouldDisableStatsForCurrentMatch() self.statsDisabledReason = self.isStatsDisabledForCurrentMatch and self:getStatsDisabledReason() or "" rawPrintFn( nil, ((("[GameStatsTracker] Старт матча: session=" .. self.currentSessionId) .. ", gameTime=") .. tostring(math.floor(self.gameStartTime))) .. "s" ) if self.isStatsDisabledForCurrentMatch then ____print(nil, "[GameStatsTracker] 🚫 Трекинг матча отключен: " .. self.statsDisabledReason) end if self.pendingPlayers.size > 0 then ____print( nil, ("[GameStatsTracker] 📋 Регистрируем " .. tostring(self.pendingPlayers.size)) .. " игроков, которые появились до начала игры" ) for ____, playerId in __TS__Iterator(self.pendingPlayers) do do if not self.sessionParticipantIdSet:has(playerId) then goto __continue46 end local hero = PlayerResource:GetSelectedHeroEntity(playerId) if hero and hero:IsRealHero() then self:registerPlayerGame(playerId, hero) end end ::__continue46:: end self.pendingPlayers:clear() end self:startGameForAllPlayers() self:startDisconnectCheck() self:startHeartbeat() end function GameStatsTracker.prototype.shouldDisableStatsForCurrentMatch(self) if not ALLOW_STATS_IN_TOOLS_MODE and self:isToolsModeActive() then return true end if not ALLOW_STATS_WITH_CHEATS and self:isCheatsEnabled() then return true end return false end function GameStatsTracker.prototype.getStatsDisabledReason(self) local reasons = {} if not ALLOW_STATS_IN_TOOLS_MODE and self:isToolsModeActive() then reasons[#reasons + 1] = "tools_mode" end if not ALLOW_STATS_WITH_CHEATS and self:isCheatsEnabled() then reasons[#reasons + 1] = "cheats_enabled" end return table.concat(reasons, "+") end function GameStatsTracker.prototype.isToolsModeActive(self) do local function ____catch() return true, false end local ____try, ____hasReturned, ____returnValue = pcall(function() local fn = _G.IsInToolsMode local ____fn_0 if fn then ____fn_0 = fn(nil) else ____fn_0 = false end return true, ____fn_0 end) if not ____try then ____hasReturned, ____returnValue = ____catch() end if ____hasReturned then return ____returnValue end end end function GameStatsTracker.prototype.isCheatsEnabled(self) do local ____try, ____hasReturned, ____returnValue = pcall(function() local ____this_2 ____this_2 = GameRules local ____opt_1 = ____this_2.IsCheatMode if ____opt_1 ~= nil then ____opt_1 = ____opt_1(____this_2) end if ____opt_1 then return true, true end end) if ____try and ____hasReturned then return ____returnValue end end do local ____try, ____hasReturned, ____returnValue = pcall(function() local cv = _G.Convars if cv and type(cv.GetBool) == "function" and cv:GetBool("sv_cheats") then return true, true end end) if ____try and ____hasReturned then return ____returnValue end end return false end function GameStatsTracker.prototype.startDisconnectCheck(self) if self.disconnectCheckInterval ~= nil then Timers:RemoveTimer(self.disconnectCheckInterval) end self.disconnectCheckInterval = Timers:CreateTimer( 2, function() if not self.isGameStarted or self.isGameEnded then return nil end self:checkForDisconnectedPlayers() return 2 end ) end function GameStatsTracker.prototype.checkForDisconnectedPlayers(self) local currentTime = GameRules:GetGameTime() local activelyConnected = self:collectActivelyConnectedPlayers() for ____, playerId in __TS__Iterator(activelyConnected) do if self.playerDisconnectTime:has(playerId) then self.playerDisconnectTime:delete(playerId) end end for ____, playerId in __TS__Iterator(self.lastKnownPlayers) do do if not self.playerGameData:has(playerId) then goto __continue72 end if not PlayerResource:IsValidPlayer(playerId) then if not self.playerDisconnectTime:has(playerId) then ____print( nil, ("[GameStatsTracker] ⚠️ Игрок " .. tostring(playerId)) .. " больше не валиден (полностью вышел из игры)" ) self.playerDisconnectTime:set(playerId, currentTime) else local disconnectTime = self.playerDisconnectTime:get(playerId) if currentTime - disconnectTime >= self:getDisconnectTimeoutForPlayer(playerId) then self:handlePermanentDisconnect(playerId) end end goto __continue72 end if activelyConnected:has(playerId) then goto __continue72 end if not self.playerDisconnectTime:has(playerId) then local playerName = PlayerResource:GetPlayerName(playerId) local steamId = tostring(PlayerResource:GetSteamAccountID(playerId)) local cs = PlayerResource:GetConnectionState(playerId) ____print( nil, ((((("[GameStatsTracker] ⚠️ Игрок " .. playerName) .. " (SteamID: ") .. steamId) .. ") отключился (connectionState: ") .. tostring(cs)) .. ")" ) self.playerDisconnectTime:set(playerId, currentTime) else local disconnectTime = self.playerDisconnectTime:get(playerId) if currentTime - disconnectTime >= self:getDisconnectTimeoutForPlayer(playerId) then self:handlePermanentDisconnect(playerId) end end end ::__continue72:: end for ____, playerId in __TS__Iterator(self:collectEffectivePlayersInGame()) do self.lastKnownPlayers:add(playerId) end for ____, playerId in __TS__Iterator(self.playerGameData:keys()) do self.lastKnownPlayers:add(playerId) end if self.isGameStarted and not self.isGameEnded and self.playerGameData.size > 0 then if activelyConnected.size == 0 then if self.allLeftDebounceSinceGameTime == nil then self.allLeftDebounceSinceGameTime = currentTime elseif currentTime - self.allLeftDebounceSinceGameTime >= ALL_LEFT_DEBOUNCE_SECONDS then ____print( nil, ("[GameStatsTracker] ⚠️ Все игроки покинули игру (debounce " .. tostring(ALL_LEFT_DEBOUNCE_SECONDS)) .. "s). Сохраняем результаты и завершаем игру." ) local playersToHandle = {} for ____, playerId in __TS__Iterator(self.playerGameData:keys()) do playersToHandle[#playersToHandle + 1] = playerId end for ____, playerId in ipairs(playersToHandle) do self:handlePermanentDisconnect(playerId) end self.isGameEnded = true self.allLeftDebounceSinceGameTime = nil self:stopDisconnectCheck() self:stopHeartbeat() end else self.allLeftDebounceSinceGameTime = nil end end end function GameStatsTracker.prototype.handlePermanentDisconnect(self, playerId) local playerName = PlayerResource:GetPlayerName(playerId) local steamId = tostring(PlayerResource:GetSteamAccountID(playerId)) local disconnectDuration = GameRules:GetGameTime() - (self.playerDisconnectTime:get(playerId) or self.gameStartTime) ____print(nil, ((("[GameStatsTracker] ⚠️ Игрок " .. playerName) .. " (SteamID: ") .. steamId) .. ") окончательно отключился") ____print( nil, ("[GameStatsTracker] Время отключения: " .. tostring(math.floor(disconnectDuration))) .. " секунд назад" ) self.playerDisconnectTime:delete(playerId) self.lastKnownPlayers:delete(playerId) self:onPlayerDisconnect(playerId) end function GameStatsTracker.prototype.startGameForAllPlayers(self) local gameState = GameRules:State_Get() if gameState ~= 5 and not self.isGameStarted then ____print( nil, ("[GameStatsTracker] ⚠️ Игра не в состоянии IN_PROGRESS (текущее состояние: " .. tostring(gameState)) .. ") и еще не началась, пропускаем регистрацию" ) return end if gameState ~= 5 and self.isGameStarted then ____print( nil, ("[GameStatsTracker] ⚠️ Игра началась, но состояние не IN_PROGRESS (" .. tostring(gameState)) .. "), продолжаем регистрацию" ) end local currentDifficulty = Difficulty.leader or "normal" ____print(nil, ("[GameStatsTracker] 📋 Начинаем регистрацию игры для всех игроков (сложность: " .. currentDifficulty) .. ")") ____print( nil, ("[GameStatsTracker] 📊 Снимок лобби: " .. tostring(#self.sessionParticipantsOrdered)) .. " слотов (только реальные игроки)" ) local playersCount = 0 local skippedNoHero = 0 for ____, playerId in ipairs(self.sessionParticipantsOrdered) do do ____print( nil, ("[GameStatsTracker] 🔍 Слот " .. tostring(playerId)) .. " (из снимка сессии)..." ) local hero = PlayerResource:GetSelectedHeroEntity(playerId) if not hero then ____print( nil, ("[GameStatsTracker] ⚠️ У игрока " .. tostring(playerId)) .. " ещё нет героя" ) skippedNoHero = skippedNoHero + 1 goto __continue100 end if self:registerPlayerGame(playerId, hero) then playersCount = playersCount + 1 end end ::__continue100:: end ____print(nil, "[GameStatsTracker] 📊 Регистрация завершена:") ____print( nil, "[GameStatsTracker] ✅ Запросов на регистрацию отправлено: " .. tostring(playersCount) ) ____print( nil, "[GameStatsTracker] ⚠️ Пропущено (герой не готов): " .. tostring(skippedNoHero) ) if playersCount == 0 then ____print(nil, "[GameStatsTracker] ⚠️ ВНИМАНИЕ: Не найдено ни одного игрока для регистрации!") ____print(nil, "[GameStatsTracker] Возможно, игра началась до того, как игроки выбрали героев.") ____print(nil, "[GameStatsTracker] Будем регистрировать игроков при появлении их героев.") end end function GameStatsTracker.prototype.registerPlayerGame(self, playerId, hero) if self.isStatsDisabledForCurrentMatch then return false end if not self.sessionParticipantIdSet:has(playerId) then ____print( nil, ("[GameStatsTracker] ⏭️ registerPlayerGame: слот " .. tostring(playerId)) .. " не в снимке сессии" ) return false end if self.playerGameData:has(playerId) then return false end if self.sharedMatchId == nil and self.matchBootstrapPlayerId ~= nil and self.matchBootstrapPlayerId ~= playerId then self.queuedPlayersForMatchBootstrap:add(playerId) local queuedName = PlayerResource:GetPlayerName(playerId) ____print(nil, "[GameStatsTracker] ⏳ Ожидаем общий match_id, откладываем регистрацию игрока " .. queuedName) return false end if self.registerStartInFlight:has(playerId) then return false end if self.sharedMatchId == nil and self.matchBootstrapPlayerId == nil then self.matchBootstrapPlayerId = playerId local bootstrapName = PlayerResource:GetPlayerName(playerId) ____print(nil, "[GameStatsTracker] 🚀 Bootstrap match_id через игрока " .. bootstrapName) end self.registerStartInFlight:add(playerId) self.lastKnownPlayers:add(playerId) local steamId = tostring(PlayerResource:GetSteamAccountID(playerId)) local playerName = PlayerResource:GetPlayerName(playerId) local heroLevel = hero:GetLevel() local currentDifficulty = Difficulty.leader or "normal" ____print( nil, (((((("[GameStatsTracker] 👤 Регистрируем игрока " .. playerName) .. " (SteamID: ") .. steamId) .. "), герой: ") .. hero:GetUnitName()) .. ", уровень: ") .. tostring(heroLevel) ) local request = CreateHTTPRequest("POST", SERVER_CONFIG.API_URL .. "/game/start") setApiHeadersLong(nil, request) local dataToSend = { steam_id = steamId, hero = hero:GetUnitName(), hero_level = heroLevel, difficulty = currentDifficulty, player_name = playerName, match_id = self.sharedMatchId, session_id = self.currentSessionId, session_participants = self.sessionParticipantsSteamOrdered } local dsContractStart = Difficulty:getActiveDeathSentenceContractPayload() if dsContractStart then dataToSend.death_sentence_contract = dsContractStart end request:SetHTTPRequestRawPostBody( "application/json", encodeApiBody(nil, dataToSend) ) request:Send(function(result) self.registerStartInFlight:delete(playerId) if result.StatusCode >= 200 and result.StatusCode < 300 then do local function ____catch(e) ____print(nil, ("[GameStatsTracker] ❌ Ошибка парсинга ответа для игрока " .. playerName) .. ":", e) if not self.playerGameData:has(playerId) then self.lastKnownPlayers:delete(playerId) end self:onRegisterStartFinished(playerId) end local ____try, ____hasReturned = pcall(function() local responseData = {json.decode(result.Body)} local data = nil if __TS__ArrayIsArray(responseData) and #responseData > 0 then data = responseData[1] elseif responseData.value ~= nil then data = responseData.value else data = responseData end if data and data.game_id and data.match_id then if self.sharedMatchId == nil then self.sharedMatchId = __TS__Number(data.match_id) or nil if self.sharedMatchId ~= nil then ____print( nil, "[GameStatsTracker] 🔗 Зафиксирован общий Match ID: " .. tostring(self.sharedMatchId) ) end end self.playerGameData:set(playerId, {game_id = data.game_id, match_id = data.match_id}) self.lastKnownPlayers:add(playerId) ____print(nil, "[GameStatsTracker] ✅ Игра зарегистрирована для игрока " .. playerName) ____print( nil, (("[GameStatsTracker] Game ID: " .. tostring(data.game_id)) .. ", Match ID: ") .. tostring(data.match_id) ) else ____print(nil, ("[GameStatsTracker] ⚠️ Неполный ответ от сервера для игрока " .. playerName) .. ":", data) if not self.playerGameData:has(playerId) then self.lastKnownPlayers:delete(playerId) end end self:onRegisterStartFinished(playerId) end) if not ____try then ____catch(____hasReturned) end end else ____print( nil, (("[GameStatsTracker] ❌ Ошибка при регистрации игры для игрока " .. playerName) .. ": StatusCode ") .. tostring(result.StatusCode) ) if not self.playerGameData:has(playerId) then self.lastKnownPlayers:delete(playerId) end self:onRegisterStartFinished(playerId) end end) return true end function GameStatsTracker.prototype.onRegisterStartFinished(self, playerId) if self.matchBootstrapPlayerId ~= playerId then return end self.matchBootstrapPlayerId = nil if self.queuedPlayersForMatchBootstrap.size == 0 then return end local queued = __TS__ArrayFrom(self.queuedPlayersForMatchBootstrap) self.queuedPlayersForMatchBootstrap:clear() ____print( nil, "[GameStatsTracker] ▶️ Продолжаем регистрацию отложенных игроков: " .. tostring(#queued) ) for ____, queuedPlayerId in ipairs(queued) do do local hero = PlayerResource:GetSelectedHeroEntity(queuedPlayerId) if not hero or not hero:IsRealHero() then goto __continue131 end self:registerPlayerGame(queuedPlayerId, hero) end ::__continue131:: end end function GameStatsTracker.prototype.onVictory(self, callback) if self.isGameEnded then return end if not self.isGameStarted then ____print(nil, "[GameStatsTracker] onVictory: матч не был отмечен как начатый — колбэк для UI, без сохранения API") self.isGameEnded = true if callback then callback(nil) end return end local duration = GameRules:GetGameTime() - self.gameStartTime rawPrintFn( nil, ("[GameStatsTracker] Завершение матча: result=win, duration=" .. tostring(math.floor(duration))) .. "s" ) self:stopDisconnectCheck() self:stopHeartbeat() if self.isStatsDisabledForCurrentMatch then ____print(nil, ("[GameStatsTracker] 🚫 Победа не сохраняется в статистику (" .. self.statsDisabledReason) .. ")") self.isGameEnded = true if callback then callback(nil) end return end self:saveAllPlayersStats(true, callback) self.isGameEnded = true end function GameStatsTracker.prototype.onDefeat(self, callback) if self.isGameEnded then return end if not self.isGameStarted then ____print(nil, "[GameStatsTracker] onDefeat: матч не был отмечен как начатый — колбэк для UI, без сохранения API") self.isGameEnded = true if callback then callback(nil) end return end local duration = GameRules:GetGameTime() - self.gameStartTime rawPrintFn( nil, ("[GameStatsTracker] Завершение матча: result=loss, duration=" .. tostring(math.floor(duration))) .. "s" ) self:stopDisconnectCheck() self:stopHeartbeat() if self.isStatsDisabledForCurrentMatch then ____print(nil, ("[GameStatsTracker] 🚫 Поражение не сохраняется в статистику (" .. self.statsDisabledReason) .. ")") self.isGameEnded = true if callback then callback(nil) end return end self:saveAllPlayersStats(false, callback) self.isGameEnded = true end function GameStatsTracker.prototype.isCurrentMatchStatsDisabled(self) return self.isStatsDisabledForCurrentMatch end function GameStatsTracker.prototype.shouldBlockMatchEndRewards(self) if not ALLOW_STATS_IN_TOOLS_MODE and self:isToolsModeActive() and not ALLOW_MATCH_END_REWARDS_IN_TOOLS then return true end if not ALLOW_STATS_WITH_CHEATS and self:isCheatsEnabled() and not ALLOW_MATCH_END_REWARDS_WITH_CHEATS then return true end return false end function GameStatsTracker.prototype.startHeartbeat(self) if self.heartbeatInterval ~= nil then Timers:RemoveTimer(self.heartbeatInterval) end self.heartbeatInterval = Timers:CreateTimer( HEARTBEAT_INTERVAL_SECONDS, function() if not self.isGameStarted or self.isGameEnded then return nil end self:sendHeartbeat() return HEARTBEAT_INTERVAL_SECONDS end ) self:sendHeartbeat() end function GameStatsTracker.prototype.stopHeartbeat(self) if self.heartbeatInterval ~= nil then Timers:RemoveTimer(self.heartbeatInterval) self.heartbeatInterval = nil end if self.heartbeatRetryTimer ~= nil then Timers:RemoveTimer(self.heartbeatRetryTimer) self.heartbeatRetryTimer = nil end end function GameStatsTracker.prototype.scheduleHeartbeatRetry(self) if not self.isGameStarted or self.isGameEnded then return end if self.heartbeatRetryTimer ~= nil then return end self.heartbeatRetryTimer = Timers:CreateTimer( HEARTBEAT_RETRY_SECONDS, function() self.heartbeatRetryTimer = nil if not self.isGameStarted or self.isGameEnded then return nil end self:sendHeartbeat() return nil end ) end function GameStatsTracker.prototype.sendHeartbeat(self) if not self.isGameStarted or self.isGameEnded then return end if self.isStatsDisabledForCurrentMatch then return end do local i = 0 while i < DOTA_MAX_PLAYERS do do local playerId = i if not PlayerResource:IsValidPlayerID(playerId) then goto __continue165 end if not self:isPlayerActivelyConnected(playerId) then goto __continue165 end local gameData = self.playerGameData:get(playerId) if not gameData or not gameData.game_id then goto __continue165 end local steamId = tostring(PlayerResource:GetSteamAccountID(playerId)) local playerName = PlayerResource:GetPlayerName(playerId) local requestData = {steam_id = steamId, game_id = gameData.game_id} local request = CreateHTTPRequestScriptVM("POST", SERVER_CONFIG.API_URL .. "/game/heartbeat") setApiHeadersLong(nil, request) request:SetHTTPRequestRawPostBody( "application/json", encodeApiBody(nil, requestData) ) request:Send(function(result) local accepted = false if result.StatusCode >= 200 and result.StatusCode < 300 then do local function ____catch(e) ____print(nil, ("[GameStatsTracker] ⚠️ Ошибка парсинга ответа heartbeat для игрока " .. playerName) .. ":", e) end local ____try, ____hasReturned = pcall(function() local responseData = {json.decode(result.Body)} local data = nil if __TS__ArrayIsArray(responseData) and #responseData > 0 then data = responseData[1] elseif responseData.value ~= nil then data = responseData.value elseif responseData and type(responseData) == "table" then data = responseData end if data and data.success then accepted = true ____print( nil, ((("[GameStatsTracker] 💓 Heartbeat отправлен для игрока " .. playerName) .. " (Game ID: ") .. tostring(gameData.game_id)) .. ")" ) end end) if not ____try then ____catch(____hasReturned) end end else ____print( nil, (("[GameStatsTracker] ⚠️ Ошибка отправки heartbeat для игрока " .. playerName) .. ": StatusCode ") .. tostring(result.StatusCode) ) end if not accepted then self:scheduleHeartbeatRetry() end end) end ::__continue165:: i = i + 1 end end end function GameStatsTracker.prototype.stopDisconnectCheck(self) if self.disconnectCheckInterval ~= nil then Timers:RemoveTimer(self.disconnectCheckInterval) self.disconnectCheckInterval = nil end self.playerDisconnectTime:clear() end function GameStatsTracker.prototype.onPlayerDisconnect(self, playerId) if not self.isGameStarted or self.isGameEnded then return end if self.isStatsDisabledForCurrentMatch then self.playerGameData:delete(playerId) return end local playerName = PlayerResource:GetPlayerName(playerId) local steamId = tostring(PlayerResource:GetSteamAccountID(playerId)) local gameData = self.playerGameData:get(playerId) if not gameData or not gameData.game_id then ____print(nil, ("[GameStatsTracker] ⚠️ Игрок " .. playerName) .. " отключился до начала игры, данные не сохраняются") self.playerGameData:delete(playerId) return end ____print( nil, ((("[GameStatsTracker] 💾 Сохраняем статистику отключившегося игрока " .. playerName) .. " (Game ID: ") .. tostring(gameData.game_id)) .. ")" ) local hero = PlayerResource:GetSelectedHeroEntity(playerId) local heroName = hero and hero:GetUnitName() or "unknown" local heroLevel = hero and hero:GetLevel() or 1 local ____hero_3 if hero then ____hero_3 = hero:HasScepter() else ____hero_3 = false end local hasAghs = ____hero_3 local ____hero_4 if hero then ____hero_4 = HasShard(nil, hero) else ____hero_4 = false end local hasShard = ____hero_4 local duration = GameRules:GetGameTime() - self.gameStartTime local kills = hero and PlayerResource:GetNearbyCreepDeaths(playerId) or 0 local deaths = hero and PlayerResource:GetDeaths(playerId) or 0 local netWorth = hero and PlayerResource:GetNetWorth(playerId) or 0 local combat = MatchEndCombatStats:getInstance() local outgoingDamage = combat:getOutgoingDamageSum(playerId) local incomingDamage = combat:getIncomingDamageSum(playerId) local items = {} local permanentModifiers = {} if hero then do local i = 0 while i < 9 do local item = hero:GetItemInSlot(i) if item and item:GetAbilityName() then items[#items + 1] = item:GetAbilityName() end i = i + 1 end end local modifierCounts = {} do local i = 0 while i < hero:GetModifierCount() do local modifierName = hero:GetModifierNameByIndex(i) if modifierName ~= nil then local buff = hero:FindModifierByName(modifierName) if buff then local buffDuration = buff:GetDuration() local remainingTime = buff:GetRemainingTime() if buffDuration == -1 or buffDuration > 0 and remainingTime > 99999 then modifierCounts[modifierName] = (modifierCounts[modifierName] or 0) + 1 end end end i = i + 1 end end for modName in pairs(modifierCounts) do permanentModifiers[#permanentModifiers + 1] = (modName .. ":") .. tostring(modifierCounts[modName]) end end local currentDifficulty = Difficulty.leader or "normal" local function finishDisconnectSave() self:saveGameResult( steamId, false, duration, kills, deaths, netWorth, outgoingDamage, incomingDamage, heroName, heroLevel, items, permanentModifiers, hasAghs, hasShard, currentDifficulty, gameData.game_id, function() ____print(nil, ("[GameStatsTracker] ✅ Статистика отключившегося игрока " .. playerName) .. " сохранена") self.playerGameData:delete(playerId) end ) end if currentDifficulty == "death_sentence" then Difficulty:applyDeathSentenceContractDurabilityOnLoss(finishDisconnectSave) else finishDisconnectSave(nil) end end function GameStatsTracker.prototype.saveAllPlayersStats(self, isVictory, callback) if self.isStatsDisabledForCurrentMatch then ____print(nil, ("[GameStatsTracker] 🚫 saveAllPlayersStats пропущен (" .. self.statsDisabledReason) .. ")") if callback then callback(nil) end return end local duration = GameRules:GetGameTime() - self.gameStartTime local savedCount = 0 local resultText = isVictory and "ПОБЕДА" or "ПОРАЖЕНИЕ" ____print(nil, ("[GameStatsTracker] 💾 Сохраняем статистику для всех игроков (" .. resultText) .. ")") local idsToSave = {} do local i = 0 while i < DOTA_MAX_PLAYERS do do local playerId = i if not PlayerResource:IsValidPlayerID(playerId) then goto __continue201 end if not PlayerResource:IsValidPlayer(playerId) or PlayerResource:IsFakeClient(playerId) then goto __continue201 end if not self.playerGameData:has(playerId) then goto __continue201 end local hero = PlayerResource:GetSelectedHeroEntity(playerId) if hero then idsToSave[#idsToSave + 1] = playerId end end ::__continue201:: i = i + 1 end end local totalPlayers = #idsToSave if totalPlayers == 0 then ____print(nil, "[GameStatsTracker] ⚠️ Нет активных игроков для сохранения статистики") if callback then callback(nil) end return end ____print( nil, ("[GameStatsTracker] 📊 Сохраняем статистику для " .. tostring(totalPlayers)) .. " игроков" ) local combat = MatchEndCombatStats:getInstance() for ____, playerId in ipairs(idsToSave) do do local hero = PlayerResource:GetSelectedHeroEntity(playerId) if not hero then savedCount = savedCount + 1 ____print( nil, ("[GameStatsTracker] ⚠️ Герой пропал для слота " .. tostring(playerId)) .. " перед save — учитываем чанк" ) if savedCount >= totalPlayers and callback then ____print( nil, ((("[GameStatsTracker] 🎉 Все чанки учтены (" .. tostring(savedCount)) .. "/") .. tostring(totalPlayers)) .. ")" ) callback(nil) end goto __continue208 end local steamId = tostring(PlayerResource:GetSteamAccountID(playerId)) local heroName = hero:GetUnitName() local gameData = self.playerGameData:get(playerId) local gameId = gameData and gameData.game_id local kills = PlayerResource:GetNearbyCreepDeaths(playerId) local deaths = PlayerResource:GetDeaths(playerId) local netWorth = PlayerResource:GetNetWorth(playerId) local outgoingDamage = combat:getOutgoingDamageSum(playerId) local incomingDamage = combat:getIncomingDamageSum(playerId) local heroLevel = hero:GetLevel() local items = {} local permanentModifiers = {} local hasAghs = hero:HasScepter() local hasShard = HasShard(nil, hero) do local slot = 0 while slot < 9 do local item = hero:GetItemInSlot(slot) if item and item:GetAbilityName() then items[#items + 1] = item:GetAbilityName() end slot = slot + 1 end end local modifierCounts = {} do local j = 0 while j < hero:GetModifierCount() do local modifierName = hero:GetModifierNameByIndex(j) if modifierName ~= nil then local buff = hero:FindModifierByName(modifierName) if buff then local buffDuration = buff:GetDuration() local remainingTime = buff:GetRemainingTime() if buffDuration == -1 or buffDuration > 0 and remainingTime > 99999 then modifierCounts[modifierName] = (modifierCounts[modifierName] or 0) + 1 end end end j = j + 1 end end for modName in pairs(modifierCounts) do permanentModifiers[#permanentModifiers + 1] = (modName .. ":") .. tostring(modifierCounts[modName]) end local currentDifficulty = Difficulty.leader or "normal" self:saveGameResult( steamId, isVictory, duration, kills, deaths, netWorth, outgoingDamage, incomingDamage, hero:GetUnitName(), heroLevel, items, permanentModifiers, hasAghs, hasShard, currentDifficulty, gameId, function() savedCount = savedCount + 1 ____print( nil, ((((("[GameStatsTracker] ✅ Статистика сохранена для игрока " .. heroName) .. " (") .. tostring(savedCount)) .. "/") .. tostring(totalPlayers)) .. ")" ) if savedCount >= totalPlayers and callback then ____print( nil, ("[GameStatsTracker] 🎉 Все статистики сохранены! (" .. tostring(savedCount)) .. " игроков)" ) callback(nil) end end ) end ::__continue208:: end end function GameStatsTracker.prototype.saveGameResult(self, steamId, isVictory, duration, kills, deaths, score, outgoingDamage, incomingDamage, hero, heroLevel, items, modifiers, hasAghs, hasShard, difficulty, gameId, callback) local dataToSend = { steam_id = steamId, result = isVictory and "win" or "loss", duration = math.floor(duration), kills = kills, deaths = deaths, score = score, outgoing_damage = math.floor(outgoingDamage), incoming_damage = math.floor(incomingDamage), hero = hero, hero_level = heroLevel, items = table.concat(items, ","), modifiers = table.concat(modifiers, ","), aghanim_scepter = hasAghs, aghanim_shard = hasShard, gold_earned = score, difficulty = difficulty, session_id = self.currentSessionId } if gameId ~= nil then dataToSend.game_id = gameId end local dsContractEnd = Difficulty:getActiveDeathSentenceContractPayload() if dsContractEnd then dataToSend.death_sentence_contract = dsContractEnd end local request = CreateHTTPRequest("POST", SERVER_CONFIG.API_URL .. "/game") setApiHeadersLong(nil, request) request:SetHTTPRequestRawPostBody( "application/json", encodeApiBody(nil, dataToSend) ) request:Send(function(result) if result.StatusCode < 200 or result.StatusCode >= 300 then local body = result.Body ~= nil and tostring(result.Body) or "" rawPrintFn( nil, (((("[GameStatsTracker] saveGameResult HTTP " .. tostring(result.StatusCode)) .. " steam_id=") .. steamId) .. " body=") .. body ) end if callback then callback(nil) end end) end return ____exports