Files
Dota-Zombie-Invasion/scripts/vscripts/game_stats_tracker.lua
T
2026-05-29 15:11:31 +07:00

1181 lines
49 KiB
Lua
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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