390 lines
17 KiB
Lua
390 lines
17 KiB
Lua
-- Push current Client battle-statistic snapshots to the configured GaC SDK.
|
|||
|
|
-- Reads one known BattleModule property; no assembly or method enumeration.
|
||
|
|
-- Keep the corrected reader isolated from earlier callbacks that may still be
|
||
|
|
-- registered in the Client until its Lua event handlers are refreshed.
|
||
|
|
-- Older callbacks may survive a script refresh in the same Client session.
|
||
|
|
if _G.__GAC_DAMAGE_ANALYST_LIVE_V4 then
|
||
|
|
_G.__GAC_DAMAGE_ANALYST_LIVE_V4.nextSample = math.huge
|
||
|
|
end
|
||
|
|
if _G.__GAC_DAMAGE_ANALYST_LIVE_V5 then
|
||
|
|
_G.__GAC_DAMAGE_ANALYST_LIVE_V5.nextSample = math.huge
|
||
|
|
end
|
||
|
|
if _G.__GAC_DAMAGE_ANALYST_LIVE_V6 then
|
||
|
|
_G.__GAC_DAMAGE_ANALYST_LIVE_V6.nextSample = math.huge
|
||
|
|
end
|
||
|
|
local state = _G.__GAC_DAMAGE_ANALYST_LIVE_V7
|
||
|
|
if not state then
|
||
|
|
state = {registered=false, inBattle=false, nextSample=0, pending=nil, queuedBody=nil}
|
||
|
|
_G.__GAC_DAMAGE_ANALYST_LIVE_V7 = state
|
||
|
|
end
|
||
|
|
|
||
|
|
local Time = CS.UnityEngine.Time
|
||
|
|
local Camera = CS.UnityEngine.Camera
|
||
|
|
if state.sourceRevision ~= 2 then
|
||
|
|
state.battleWorld = nil
|
||
|
|
state.sourceRevision = 2
|
||
|
|
end
|
||
|
|
if state.detailsRevision ~= 3 then
|
||
|
|
state.lastFinalSignature = nil
|
||
|
|
state.detailsRevision = 3
|
||
|
|
end
|
||
|
|
|
||
|
|
local function logOnce(key, message)
|
||
|
|
if state[key] ~= message then
|
||
|
|
state[key] = message
|
||
|
|
CS.UnityEngine.Debug.Log("[GaC Damage Analyst] " .. tostring(message))
|
||
|
|
end
|
||
|
|
end
|
||
|
|
|
||
|
|
local function isBattleActive()
|
||
|
|
local ok, active = pcall(function()
|
||
|
|
if not state.phaseField then
|
||
|
|
local flags = CS.System.Enum.ToObject(typeof(CS.System.Reflection.BindingFlags), 60)
|
||
|
|
state.phaseField = typeof(CS.RPG.Client.GamePhaseManager):GetField("_CurrentPhase", flags)
|
||
|
|
end
|
||
|
|
local manager = CS.RPG.Client.GlobalVars.s_GamePhaseManager
|
||
|
|
local phase = manager and state.phaseField:GetValue(manager)
|
||
|
|
return phase ~= nil and typeof(CS.RPG.Client.BattleGamePhase):IsInstanceOfType(phase)
|
||
|
|
end)
|
||
|
|
if not ok then
|
||
|
|
logOnce("phaseError", active)
|
||
|
|
return false
|
||
|
|
end
|
||
|
|
return active
|
||
|
|
end
|
||
|
|
|
||
|
|
local function readKnownField(object, name)
|
||
|
|
if object == nil then return nil end
|
||
|
|
if type(object) == "table" then return object[name] end
|
||
|
|
if not state.fieldCache then state.fieldCache = {} end
|
||
|
|
local typeName = tostring(object:GetType().FullName)
|
||
|
|
local key = typeName .. ":" .. name
|
||
|
|
local field = state.fieldCache[key]
|
||
|
|
if field == nil then
|
||
|
|
local flags = CS.System.Enum.ToObject(typeof(CS.System.Reflection.BindingFlags), 60)
|
||
|
|
field = object:GetType():GetField(name, flags)
|
||
|
|
state.fieldCache[key] = field or false
|
||
|
|
end
|
||
|
|
if field == false then return nil end
|
||
|
|
return field:GetValue(object)
|
||
|
|
end
|
||
|
|
|
||
|
|
local function breakdownJSON(character)
|
||
|
|
local function dictionary(name)
|
||
|
|
local value = readKnownField(character, name)
|
||
|
|
return value and CS.Newtonsoft.Json.JsonConvert.SerializeObject(value) or '{}'
|
||
|
|
end
|
||
|
|
return '{"attack_type_damage":' .. dictionary("FNFFCPLHPGI") ..
|
||
|
|
',"attack_type_break_damage":' .. dictionary("HNJNDLEFMBE") ..
|
||
|
|
',"attack_type_max_damage":' .. dictionary("KEOOJBHFLFD") .. '}'
|
||
|
|
end
|
||
|
|
|
||
|
|
local function activeStatistics()
|
||
|
|
local manager = CS.RPG.Client.GlobalVars.s_GamePhaseManager
|
||
|
|
local phase = state.phaseField:GetValue(manager)
|
||
|
|
local instance = phase.BattleInstanceRef
|
||
|
|
if instance == nil then return nil end
|
||
|
|
local world = readKnownField(instance, "_GameWorld")
|
||
|
|
if world ~= state.battleWorld then
|
||
|
|
state.battleWorld = world
|
||
|
|
state.statComponent = nil
|
||
|
|
state.componentArray = nil
|
||
|
|
state.componentIndex = 0
|
||
|
|
state.entityIndex = 0
|
||
|
|
state.battleInfoBase64 = nil
|
||
|
|
local info = CS.RPG.Client.GlobalVars.s_ModuleManager.BattleModule:GetBattleInfo()
|
||
|
|
if info ~= nil then
|
||
|
|
state.battleInfoBase64 = CS.System.Convert.ToBase64String(CS.Google.Protobuf.MessageExtensions.ToByteArray(info))
|
||
|
|
end
|
||
|
|
end
|
||
|
|
if not state.statComponent then
|
||
|
|
if not state.componentArray then
|
||
|
|
local entities = readKnownField(world, "_EntityList")
|
||
|
|
for i=state.entityIndex,math.min(state.entityIndex+15,entities.Count-1) do
|
||
|
|
local entity = entities[i]
|
||
|
|
state.entityIndex = i+1
|
||
|
|
if entity and (entity.RuntimeID == CS.RPG.GameCore.GameWorld.CommonLevelEntityRuntimeID
|
||
|
|
or entity.RuntimeID == CS.RPG.GameCore.GameWorld.BattleLevelEntityRuntimeID) then
|
||
|
|
state.componentArray = readKnownField(entity, "_ComponentArray")
|
||
|
|
state.componentIndex = 0
|
||
|
|
break
|
||
|
|
end
|
||
|
|
end
|
||
|
|
if not state.componentArray then return nil end
|
||
|
|
end
|
||
|
|
for i=state.componentIndex,math.min(state.componentIndex+31,state.componentArray.Length-1) do
|
||
|
|
state.componentIndex = i+1
|
||
|
|
local component = state.componentArray[i]
|
||
|
|
if component and typeof(CS.RPG.GameCore.StatisticsComponent):IsInstanceOfType(component) then
|
||
|
|
state.statComponent = component
|
||
|
|
break
|
||
|
|
end
|
||
|
|
end
|
||
|
|
if not state.statComponent then
|
||
|
|
if state.componentIndex >= state.componentArray.Length then state.componentArray = nil end
|
||
|
|
return nil
|
||
|
|
end
|
||
|
|
end
|
||
|
|
local component = state.statComponent
|
||
|
|
local characters = readKnownField(component, "_LightTeamStt")
|
||
|
|
if characters == nil then return nil end
|
||
|
|
local avatars, breakdowns = {}, {}
|
||
|
|
for i=0,math.min(characters.Length-1,15) do
|
||
|
|
local character = characters[i]
|
||
|
|
if character ~= nil then
|
||
|
|
avatars[#avatars+1] = character.avatarBattleInfo
|
||
|
|
-- Native dictionaries are updated during battle; their protobuf
|
||
|
|
-- lists are populated only by the final snapshot builder.
|
||
|
|
breakdowns[#breakdowns+1] = breakdownJSON(character)
|
||
|
|
end
|
||
|
|
end
|
||
|
|
local servants, servantBreakdowns = {}, {}
|
||
|
|
local servantStats = readKnownField(component, "_ServantStt")
|
||
|
|
if servantStats ~= nil then
|
||
|
|
local iterator = servantStats:GetEnumerator()
|
||
|
|
local scanned = 0
|
||
|
|
while scanned < 64 and iterator:MoveNext() do
|
||
|
|
scanned = scanned + 1
|
||
|
|
local character = iterator.Current.Value
|
||
|
|
local info = readKnownField(character, "LJLDPJFOKCE")
|
||
|
|
if info ~= nil then
|
||
|
|
servants[#servants+1] = info
|
||
|
|
servantBreakdowns[#servantBreakdowns+1] = breakdownJSON(character)
|
||
|
|
end
|
||
|
|
end
|
||
|
|
end
|
||
|
|
return {
|
||
|
|
servantBattleInfoList=servants, servantBreakdownJSON='[' .. table.concat(servantBreakdowns, ',') .. ']',
|
||
|
|
lightTeamBattleInfoList=avatars, breakdownJSON='[' .. table.concat(breakdowns, ',') .. ']',
|
||
|
|
totalBattleTurns=readKnownField(component,"_totalBattleTurns"),
|
||
|
|
totalAutoTurns=readKnownField(component,"_totalAutoTurns"),
|
||
|
|
totalBattleRounds=readKnownField(component,"_totalBattleRounds"),
|
||
|
|
costTime=readKnownField(component,"_lastSimulateTime")
|
||
|
|
}
|
||
|
|
end
|
||
|
|
|
||
|
|
local function jsonNumber(value)
|
||
|
|
local number = tonumber(value) or 0
|
||
|
|
if number ~= number or number == math.huge or number == -math.huge then return "0" end
|
||
|
|
return string.format("%.6f", number)
|
||
|
|
end
|
||
|
|
|
||
|
|
local function jsonInteger(value)
|
||
|
|
local number = tonumber(value) or 0
|
||
|
|
if number ~= number or number == math.huge or number == -math.huge then return "0" end
|
||
|
|
return string.format("%.0f", number)
|
||
|
|
end
|
||
|
|
|
||
|
|
local function buildSnapshot(statistics)
|
||
|
|
local avatars = readKnownField(statistics, "lightTeamBattleInfoList")
|
||
|
|
local luaAvatars = type(avatars) == "table"
|
||
|
|
local avatarCount = avatars and (luaAvatars and #avatars or avatars.Count) or 0
|
||
|
|
if avatarCount <= 0 then
|
||
|
|
-- Keep compatibility with protocol BattleStatistics if the Client
|
||
|
|
-- changes the type exposed by LastBattleStatistic in a later build.
|
||
|
|
avatars = statistics.BattleAvatarList
|
||
|
|
avatarCount = avatars and avatars.Count or 0
|
||
|
|
if avatarCount <= 0 then return nil end
|
||
|
|
local bytes = CS.Google.Protobuf.MessageExtensions.ToByteArray(statistics)
|
||
|
|
local byteCount = type(bytes) == "string" and #bytes or bytes.Length
|
||
|
|
if byteCount > 2*1024*1024 then error("live statistics exceed the upload limit") end
|
||
|
|
local encoded = CS.System.Convert.ToBase64String(bytes)
|
||
|
|
return {
|
||
|
|
signature=encoded,
|
||
|
|
body='{"active":true,"statistics_base64":"' .. encoded .. '"}'
|
||
|
|
}
|
||
|
|
end
|
||
|
|
|
||
|
|
local encodedAvatars = {}
|
||
|
|
local signatureParts = {}
|
||
|
|
local totalBytes = 0
|
||
|
|
for i=0,math.min(avatarCount-1,15) do
|
||
|
|
local avatar = avatars[luaAvatars and i+1 or i]
|
||
|
|
if avatar ~= nil then
|
||
|
|
-- AvatarBattleInfo is a protobuf message even though its containing
|
||
|
|
-- PVEGameStatistics object is a Client-only type and cannot be serialized.
|
||
|
|
local bytes = CS.Google.Protobuf.MessageExtensions.ToByteArray(avatar)
|
||
|
|
local byteCount = type(bytes) == "string" and #bytes or bytes.Length
|
||
|
|
totalBytes = totalBytes + byteCount
|
||
|
|
if totalBytes > 2*1024*1024 then error("live avatar statistics exceed the upload limit") end
|
||
|
|
local encoded = CS.System.Convert.ToBase64String(bytes)
|
||
|
|
encodedAvatars[#encodedAvatars+1] = '"' .. encoded .. '"'
|
||
|
|
signatureParts[#signatureParts+1] = encoded
|
||
|
|
end
|
||
|
|
end
|
||
|
|
if #encodedAvatars == 0 then return nil end
|
||
|
|
|
||
|
|
local servants = readKnownField(statistics, "servantBattleInfoList")
|
||
|
|
local luaServants = type(servants) == "table"
|
||
|
|
local servantCount = servants and (luaServants and #servants or servants.Count) or 0
|
||
|
|
if servantCount > 64 then error("too many native servant statistics") end
|
||
|
|
local encodedServants = {}
|
||
|
|
for i=0,servantCount-1 do
|
||
|
|
local info = servants[luaServants and i+1 or i]
|
||
|
|
if info ~= nil then
|
||
|
|
local bytes = CS.Google.Protobuf.MessageExtensions.ToByteArray(info)
|
||
|
|
totalBytes = totalBytes + (type(bytes) == "string" and #bytes or bytes.Length)
|
||
|
|
if totalBytes > 2*1024*1024 then error("native statistics exceed the upload limit") end
|
||
|
|
local encoded = CS.System.Convert.ToBase64String(bytes)
|
||
|
|
encodedServants[#encodedServants+1] = '"' .. encoded .. '"'
|
||
|
|
signatureParts[#signatureParts+1] = encoded
|
||
|
|
end
|
||
|
|
end
|
||
|
|
|
||
|
|
local totalBattleTurns = readKnownField(statistics, "totalBattleTurns") or statistics.TotalBattleTurns or 0
|
||
|
|
local totalAutoTurns = readKnownField(statistics, "totalAutoTurns") or statistics.TotalAutoTurns or 0
|
||
|
|
local roundCount = readKnownField(statistics, "totalBattleRounds") or statistics.RoundCnt or 0
|
||
|
|
local costTime = readKnownField(statistics, "costTime") or statistics.CostTime or 0
|
||
|
|
local breakdown = type(statistics) == "table" and statistics.breakdownJSON or nil
|
||
|
|
local servantBreakdown = type(statistics) == "table" and statistics.servantBreakdownJSON or nil
|
||
|
|
local signature = table.concat(signatureParts, "|") .. (breakdown or "") .. (servantBreakdown or "") .. table.concat({
|
||
|
|
tostring(totalBattleTurns), tostring(totalAutoTurns), tostring(roundCount), tostring(costTime)
|
||
|
|
}, ":")
|
||
|
|
local body = '{"active":true,"avatar_statistics_base64":[' .. table.concat(encodedAvatars, ",") ..
|
||
|
|
'],"total_battle_turns":' .. jsonInteger(totalBattleTurns) ..
|
||
|
|
',"total_auto_turns":' .. jsonInteger(totalAutoTurns) ..
|
||
|
|
',"round_cnt":' .. jsonInteger(roundCount) ..
|
||
|
|
',"cost_time":' .. jsonNumber(costTime) ..
|
||
|
|
(breakdown and ',"avatar_breakdowns":' .. breakdown or '') ..
|
||
|
|
',"servant_statistics_base64":[' .. table.concat(encodedServants, ',') .. ']' ..
|
||
|
|
(servantBreakdown and ',"servant_breakdowns":' .. servantBreakdown or '') ..
|
||
|
|
(state.battleInfoBase64 and ',"battle_info_base64":"' .. state.battleInfoBase64 .. '"' or '') .. '}'
|
||
|
|
return {signature=signature, body=body}
|
||
|
|
end
|
||
|
|
|
||
|
|
local function sendBody(body, finalSignature)
|
||
|
|
if state.pending then
|
||
|
|
state.queuedBody = body
|
||
|
|
state.queuedFinalSignature = finalSignature
|
||
|
|
return
|
||
|
|
end
|
||
|
|
local ok, err = pcall(function()
|
||
|
|
local url = _G.__GAC_SRTOOL_LIVE_URL
|
||
|
|
if not url or url == "" then error("SDK URL is not configured") end
|
||
|
|
-- Some client Lua bindings do not expose UnityWebRequest.Post(string, string).
|
||
|
|
-- Build the request explicitly so the overload resolution is unambiguous.
|
||
|
|
local request = CS.UnityEngine.Networking.UnityWebRequest(url .. "/api/battle_report/live", "POST")
|
||
|
|
-- XLua marshals a Lua string directly to byte[]. This body contains
|
||
|
|
-- ASCII JSON keys/numbers and base64, so it is already UTF-8 bytes.
|
||
|
|
request.uploadHandler = CS.UnityEngine.Networking.UploadHandlerRaw(body)
|
||
|
|
request.downloadHandler = CS.UnityEngine.Networking.DownloadHandlerBuffer()
|
||
|
|
request:SetRequestHeader("Content-Type", "application/json")
|
||
|
|
state.pending = {request=request, operation=request:SendWebRequest(), finalSignature=finalSignature}
|
||
|
|
end)
|
||
|
|
if not ok then logOnce("sendError", err) end
|
||
|
|
end
|
||
|
|
|
||
|
|
local function finishPendingRequest()
|
||
|
|
if not state.pending then return end
|
||
|
|
local ok, done = pcall(function() return state.pending.operation.isDone end)
|
||
|
|
if not ok or not done then return end
|
||
|
|
local pending = state.pending
|
||
|
|
state.pending = nil
|
||
|
|
pcall(function()
|
||
|
|
local code = pending.request.responseCode
|
||
|
|
if code < 200 or code >= 300 then
|
||
|
|
logOnce("httpError", "SDK live endpoint returned HTTP " .. tostring(code))
|
||
|
|
else
|
||
|
|
state.httpError = nil
|
||
|
|
if pending.finalSignature then state.lastFinalSignature = pending.finalSignature end
|
||
|
|
end
|
||
|
|
pending.request:Dispose()
|
||
|
|
end)
|
||
|
|
if state.queuedBody then
|
||
|
|
local body = state.queuedBody
|
||
|
|
local finalSignature = state.queuedFinalSignature
|
||
|
|
state.queuedBody = nil
|
||
|
|
state.queuedFinalSignature = nil
|
||
|
|
sendBody(body, finalSignature)
|
||
|
|
end
|
||
|
|
end
|
||
|
|
|
||
|
|
local function sample()
|
||
|
|
if Time.frameCount == state.lastFrame then return end
|
||
|
|
state.lastFrame = Time.frameCount
|
||
|
|
local now = tonumber(Time.unscaledTime)
|
||
|
|
if not now and os and os.clock then now = os.clock() end
|
||
|
|
if not now then return end
|
||
|
|
local nextSample = tonumber(state.nextSample) or 0
|
||
|
|
if now < nextSample then return end
|
||
|
|
state.nextSample = now + 0.75
|
||
|
|
finishPendingRequest()
|
||
|
|
|
||
|
|
if isBattleActive() then
|
||
|
|
local ok, result = pcall(function()
|
||
|
|
local statistics = activeStatistics()
|
||
|
|
if statistics == nil then return nil end
|
||
|
|
return {statistics=statistics, snapshot=buildSnapshot(statistics)}
|
||
|
|
end)
|
||
|
|
if not ok then
|
||
|
|
logOnce("statisticsError", result)
|
||
|
|
return
|
||
|
|
end
|
||
|
|
if result == nil then return end
|
||
|
|
state.statisticsError = nil
|
||
|
|
if result.snapshot == nil then
|
||
|
|
logOnce("snapshotError", "Client battle statistics have no avatar snapshots")
|
||
|
|
return
|
||
|
|
end
|
||
|
|
state.snapshotError = nil
|
||
|
|
if not state.inBattle then
|
||
|
|
-- Send the first sample immediately to replace any prior report.
|
||
|
|
state.inBattle = true
|
||
|
|
state.lastSignature = nil
|
||
|
|
state.nextHeartbeat = 0
|
||
|
|
end
|
||
|
|
if result.snapshot.signature == state.lastSignature and now < state.nextHeartbeat then return end
|
||
|
|
state.lastSignature = result.snapshot.signature
|
||
|
|
state.nextHeartbeat = now + 2.5
|
||
|
|
sendBody(result.snapshot.body)
|
||
|
|
else
|
||
|
|
state.inBattle = false
|
||
|
|
state.lastSignature = nil
|
||
|
|
state.nextHeartbeat = 0
|
||
|
|
-- The network result intentionally omits rich per-avatar counters.
|
||
|
|
-- Upload the completed native snapshot; SDK matches counters/team
|
||
|
|
-- against the received result before preserving these details.
|
||
|
|
-- Module access can temporarily be unavailable during login/logout.
|
||
|
|
local ready, statistics = pcall(function()
|
||
|
|
local manager = CS.RPG.Client.GlobalVars.s_ModuleManager
|
||
|
|
local module = manager and manager.BattleModule
|
||
|
|
return module and module.LastBattleStatistic
|
||
|
|
end)
|
||
|
|
if not ready then return end
|
||
|
|
if statistics ~= nil then
|
||
|
|
local snapshot = buildSnapshot(statistics)
|
||
|
|
if snapshot and snapshot.signature ~= state.lastFinalSignature then
|
||
|
|
local body = snapshot.body:gsub('"active":true', '"active":false,"final":true', 1)
|
||
|
|
sendBody(body, snapshot.signature)
|
||
|
|
end
|
||
|
|
end
|
||
|
|
end
|
||
|
|
end
|
||
|
|
|
||
|
|
local function runSampleSafely()
|
||
|
|
local ok, err = pcall(sample)
|
||
|
|
if not ok then logOnce("sampleError", err) end
|
||
|
|
end
|
||
|
|
state.sample = runSampleSafely
|
||
|
|
-- Also sample on heartbeat, so a background window or a render pipeline that
|
||
|
|
-- suppresses a camera event cannot stop delivery altogether.
|
||
|
|
runSampleSafely()
|
||
|
|
|
||
|
|
local function addHook(target, eventName, callback)
|
||
|
|
local ok = pcall(function() target[eventName]("+", callback) end)
|
||
|
|
if ok then return true end
|
||
|
|
return pcall(function() target[eventName] = target[eventName] + callback end)
|
||
|
|
end
|
||
|
|
|
||
|
|
if not state.registered then
|
||
|
|
local function tick() if state.sample then state.sample() end end
|
||
|
|
local cameraHooked = addHook(Camera, "onPostRender", tick)
|
||
|
|
local applicationHooked = addHook(CS.UnityEngine.Application, "onBeforeRender", tick)
|
||
|
|
state.registered = cameraHooked or applicationHooked
|
||
|
|
if not state.registered then
|
||
|
|
logOnce("hookError", "Could not register a Unity render callback; will retry")
|
||
|
|
else
|
||
|
|
state.hookError = nil
|
||
|
|
logOnce("hookReady", "Live battle sampling callback registered")
|
||
|
|
end
|
||
|
|
end
|