353 lines
12 KiB
Go
353 lines
12 KiB
Go
package common
|
|
|
|
import (
|
|
"encoding/json"
|
|
"os"
|
|
"sort"
|
|
"sync"
|
|
"time"
|
|
|
|
goproto "gac-hkrpg-go/protocol/proto"
|
|
)
|
|
|
|
var battleReportMu sync.RWMutex
|
|
|
|
const BattleReportFile = "battle_report.json"
|
|
|
|
type BattleSkillReport struct {
|
|
SkillID uint32 `json:"skill_id"`
|
|
SkillType string `json:"skill_type"`
|
|
SkillLevel uint32 `json:"skill_level"`
|
|
SkillUseCount uint32 `json:"skill_use_count"`
|
|
}
|
|
|
|
type BattleAvatarReport struct {
|
|
AvatarID uint32 `json:"avatar_id"`
|
|
Slot *uint32 `json:"slot,omitempty"`
|
|
AvatarLevel uint32 `json:"level"`
|
|
AvatarRank uint32 `json:"rank"`
|
|
TotalDamage float64 `json:"total_damage"`
|
|
MemospriteDamage float64 `json:"memosprite_damage"`
|
|
DamagePercent float64 `json:"damage_percent"`
|
|
TotalDamageTaken float64 `json:"total_damage_taken"`
|
|
TotalHeal float64 `json:"total_heal"`
|
|
TotalHpRecover float64 `json:"total_hp_recover"`
|
|
TotalShield float64 `json:"total_shield"`
|
|
TotalShieldTaken float64 `json:"total_shield_taken"`
|
|
TotalBreakDamage float64 `json:"total_break_damage"`
|
|
TotalTurns uint32 `json:"total_turns"`
|
|
ExtraTurns uint32 `json:"extra_turns"`
|
|
DieTimes uint32 `json:"die_times"`
|
|
ReviveTimes uint32 `json:"revive_times"`
|
|
BreakTimes uint32 `json:"break_times"`
|
|
AttackTypeDamage map[string]float64 `json:"attack_type_damage"`
|
|
AttackTypeBreakDamage map[string]float64 `json:"attack_type_break_damage"`
|
|
AttackTypeMaxDamage map[string]float64 `json:"attack_type_max_damage"`
|
|
SkillTimes []*BattleSkillReport `json:"skill_times"`
|
|
}
|
|
|
|
// The battle's lineup is authoritative; overworld lineup and native statistics
|
|
// list enumeration can have different orders.
|
|
func ApplyBattleReportContext(report *BattleReport, battle *goproto.SceneBattleInfo) {
|
|
if report == nil || battle == nil {
|
|
return
|
|
}
|
|
report.BattleID, report.StageID = battle.BattleId, battle.StageId
|
|
indices := make(map[uint32]uint32, len(battle.BattleAvatarList))
|
|
metadata := make(map[uint32]*goproto.BattleAvatar, len(battle.BattleAvatarList))
|
|
for _, a := range battle.BattleAvatarList {
|
|
if a != nil {
|
|
indices[a.Id] = a.Index
|
|
metadata[a.Id] = a
|
|
}
|
|
}
|
|
for _, a := range report.Avatars {
|
|
if a != nil {
|
|
if slot, ok := indices[a.AvatarID]; ok {
|
|
value := slot
|
|
a.Slot = &value
|
|
a.AvatarLevel, a.AvatarRank = metadata[a.AvatarID].Level, metadata[a.AvatarID].Rank
|
|
}
|
|
}
|
|
}
|
|
sort.SliceStable(report.Avatars, func(i, j int) bool {
|
|
a, b := report.Avatars[i], report.Avatars[j]
|
|
if a == nil || a.Slot == nil {
|
|
return false
|
|
}
|
|
if b == nil || b.Slot == nil {
|
|
return true
|
|
}
|
|
return *a.Slot < *b.Slot
|
|
})
|
|
}
|
|
|
|
type BattleMonsterReport struct {
|
|
EntityID uint32 `json:"entity_id"`
|
|
MonsterID uint32 `json:"monster_id"`
|
|
TotalTurns uint32 `json:"total_turns"`
|
|
TotalDamage float64 `json:"total_damage"`
|
|
TotalDamageTaken float64 `json:"total_damage_taken"`
|
|
Wave uint32 `json:"wave"`
|
|
AttackTypeDamage map[string]float64 `json:"attack_type_damage"`
|
|
}
|
|
|
|
type BattleReport struct {
|
|
Timestamp int64 `json:"timestamp"`
|
|
BattleID uint32 `json:"battle_id"`
|
|
StageID uint32 `json:"stage_id"`
|
|
EndStatus string `json:"end_status"` // "WIN", "FAIL", "QUIT"
|
|
TotalBattleTurns uint32 `json:"total_battle_turns"`
|
|
TotalAutoTurns uint32 `json:"total_auto_turns"`
|
|
RoundCnt uint32 `json:"round_cnt"`
|
|
CostTime float64 `json:"cost_time"`
|
|
ChallengeScore uint32 `json:"challenge_score"`
|
|
TeamTotalDamage float64 `json:"team_total_damage"`
|
|
TeamTotalHeal float64 `json:"team_total_heal"`
|
|
TeamTotalShield float64 `json:"team_total_shield"`
|
|
Avatars []*BattleAvatarReport `json:"avatars"`
|
|
Monsters []*BattleMonsterReport `json:"monsters"`
|
|
}
|
|
|
|
// BattleReportFromStatistics converts a protobuf BattleStatistics packet from the game client
|
|
// into a standardized BattleReport.
|
|
func BattleReportFromStatistics(stt *goproto.BattleStatistics, battleID uint32, stageID uint32, endStatus string) *BattleReport {
|
|
if stt == nil {
|
|
return nil
|
|
}
|
|
report := &BattleReport{
|
|
Timestamp: time.Now().Unix(),
|
|
BattleID: battleID,
|
|
StageID: stageID,
|
|
EndStatus: endStatus,
|
|
TotalBattleTurns: stt.TotalBattleTurns,
|
|
TotalAutoTurns: stt.TotalAutoTurns,
|
|
RoundCnt: stt.RoundCnt,
|
|
CostTime: stt.CostTime,
|
|
ChallengeScore: stt.ChallengeScore,
|
|
Avatars: make([]*BattleAvatarReport, 0, len(stt.BattleAvatarList)),
|
|
Monsters: make([]*BattleMonsterReport, 0, len(stt.MonsterList)),
|
|
}
|
|
|
|
// The 4.6.53 schema exposes OKFALMGFHMA entries with avatar_id and damage,
|
|
// but the enclosing message and several fields remain obfuscated. Treating
|
|
// these as per-avatar damage records is an inference that still needs a raw
|
|
// Client capture; use them only when the clear aggregate is zero.
|
|
damageByAvatar := make(map[uint32]float64)
|
|
var damageEventTotal float64
|
|
for _, event := range stt.OKFALMGFHMA {
|
|
if event == nil || event.AvatarId == 0 || event.Damage <= 0 {
|
|
continue
|
|
}
|
|
damageByAvatar[event.AvatarId] += event.Damage
|
|
damageEventTotal += event.Damage
|
|
}
|
|
attackDamageProperties := make(map[uint32][]*goproto.AttackDamageProperty, len(stt.BattleAvatarList))
|
|
attackDamageByAvatar := make(map[uint32]float64, len(stt.BattleAvatarList))
|
|
for _, avatar := range stt.BattleAvatarList {
|
|
if avatar == nil {
|
|
continue
|
|
}
|
|
properties := avatarAttackDamageProperties(avatar)
|
|
attackDamageProperties[avatar.Id] = properties
|
|
attackDamageByAvatar[avatar.Id] = sumAttackDamageProperties(properties)
|
|
}
|
|
// Some Client reports leave AvatarBattleInfo totals empty and include a
|
|
// supplemental list whose nested schema explicitly names AvatarId and Damage.
|
|
// Use it only as a fallback so two report sources are never added together.
|
|
supplementalDamageByAvatar := make(map[uint32]float64)
|
|
if stt.MFOJKJGKDGD != nil {
|
|
for _, entry := range stt.MFOJKJGKDGD.INBNCAKGDPJ {
|
|
if entry == nil || entry.AvatarId == 0 || entry.Damage <= 0 {
|
|
continue
|
|
}
|
|
supplementalDamageByAvatar[entry.AvatarId] += entry.Damage
|
|
}
|
|
}
|
|
|
|
var teamDmg, primaryAvatarDamage, teamHeal, teamShield float64
|
|
for _, a := range stt.BattleAvatarList {
|
|
if a == nil {
|
|
continue
|
|
}
|
|
damage := a.TotalDamage
|
|
if damage == 0 {
|
|
damage = damageByAvatar[a.Id]
|
|
}
|
|
if damage == 0 {
|
|
damage = attackDamageByAvatar[a.Id]
|
|
}
|
|
primaryAvatarDamage += damage
|
|
if damage == 0 {
|
|
damage = supplementalDamageByAvatar[a.Id]
|
|
}
|
|
teamDmg += damage
|
|
teamHeal += a.TotalHeal
|
|
teamShield += a.TotalShield
|
|
}
|
|
if primaryAvatarDamage == 0 && stt.MFOJKJGKDGD != nil && stt.MFOJKJGKDGD.TotalDamage > 0 {
|
|
teamDmg = stt.MFOJKJGKDGD.TotalDamage
|
|
}
|
|
// If neither avatar aggregate source contains damage, enemy damage-taken
|
|
// stats still provide a reliable team total. They cannot identify which
|
|
// avatar dealt each hit, so per-avatar shares remain unavailable in that case.
|
|
if teamDmg == 0 && damageEventTotal > 0 {
|
|
teamDmg = damageEventTotal
|
|
}
|
|
if teamDmg == 0 {
|
|
for _, m := range stt.MonsterList {
|
|
if m != nil && m.TotalDamageTaken > 0 {
|
|
teamDmg += m.TotalDamageTaken
|
|
}
|
|
}
|
|
}
|
|
report.TeamTotalDamage = teamDmg
|
|
report.TeamTotalHeal = teamHeal
|
|
report.TeamTotalShield = teamShield
|
|
|
|
for _, a := range stt.BattleAvatarList {
|
|
if a == nil {
|
|
continue
|
|
}
|
|
damage := a.TotalDamage
|
|
if damage == 0 {
|
|
damage = damageByAvatar[a.Id]
|
|
}
|
|
if damage == 0 {
|
|
damage = attackDamageByAvatar[a.Id]
|
|
}
|
|
if damage == 0 {
|
|
damage = supplementalDamageByAvatar[a.Id]
|
|
}
|
|
var dmgPercent float64
|
|
if teamDmg > 0 {
|
|
dmgPercent = (damage / teamDmg) * 100.0
|
|
}
|
|
ar := &BattleAvatarReport{
|
|
AvatarID: a.Id,
|
|
AvatarLevel: a.AvatarLevel,
|
|
AvatarRank: a.AvatarRank,
|
|
TotalDamage: damage,
|
|
DamagePercent: dmgPercent,
|
|
TotalDamageTaken: a.TotalDamageTaken,
|
|
TotalHeal: a.TotalHeal,
|
|
TotalHpRecover: a.TotalHpRecover,
|
|
TotalShield: a.TotalShield,
|
|
TotalShieldTaken: a.TotalShieldTaken,
|
|
TotalBreakDamage: a.TotalBreakDamage,
|
|
TotalTurns: a.TotalTurns,
|
|
ExtraTurns: a.ExtraTurns,
|
|
DieTimes: a.DieTimes,
|
|
ReviveTimes: a.ReviveTimes,
|
|
BreakTimes: a.BreakTimes,
|
|
AttackTypeDamage: make(map[string]float64),
|
|
AttackTypeBreakDamage: make(map[string]float64),
|
|
AttackTypeMaxDamage: make(map[string]float64),
|
|
SkillTimes: make([]*BattleSkillReport, 0, len(a.SkillTimes)),
|
|
}
|
|
for _, prop := range attackDamageProperties[a.Id] {
|
|
if prop != nil && prop.AttackType != "" {
|
|
ar.AttackTypeDamage[prop.AttackType] += prop.Damage
|
|
}
|
|
}
|
|
for _, prop := range a.AttackTypeBreakDamage {
|
|
if prop != nil && prop.AttackType != "" {
|
|
ar.AttackTypeBreakDamage[prop.AttackType] += prop.Damage
|
|
}
|
|
}
|
|
for _, prop := range a.AttackTypeMaxDamage {
|
|
if prop != nil && prop.AttackType != "" {
|
|
ar.AttackTypeMaxDamage[prop.AttackType] = prop.Damage
|
|
}
|
|
}
|
|
for _, sk := range a.SkillTimes {
|
|
if sk != nil {
|
|
ar.SkillTimes = append(ar.SkillTimes, &BattleSkillReport{
|
|
SkillID: sk.SkillId,
|
|
SkillType: sk.SkillType,
|
|
SkillLevel: sk.SkillLevel,
|
|
SkillUseCount: sk.SkillUseCount,
|
|
})
|
|
}
|
|
}
|
|
report.Avatars = append(report.Avatars, ar)
|
|
}
|
|
|
|
for _, m := range stt.MonsterList {
|
|
if m == nil {
|
|
continue
|
|
}
|
|
mr := &BattleMonsterReport{
|
|
EntityID: m.EntityId,
|
|
MonsterID: m.MonsterId,
|
|
TotalTurns: m.TotalTurns,
|
|
TotalDamage: m.TotalDamage,
|
|
TotalDamageTaken: m.TotalDamageTaken,
|
|
Wave: m.Wave,
|
|
AttackTypeDamage: make(map[string]float64),
|
|
}
|
|
for _, prop := range m.AttackTypeDamage {
|
|
if prop != nil && prop.AttackType != "" {
|
|
mr.AttackTypeDamage[prop.AttackType] += prop.Damage
|
|
}
|
|
}
|
|
report.Monsters = append(report.Monsters, mr)
|
|
}
|
|
|
|
applyBattleServantStatistics(report, stt.GetIAGKLJEKAOL().GetGFOAHHHNBJC())
|
|
return report
|
|
}
|
|
|
|
// Native snapshots show NOGHNMCDDAI matches damage taken by attack type,
|
|
// including a support with zero damage dealt. It must not become a fallback
|
|
// for damage dealt merely because it shares the AttackDamageProperty schema.
|
|
func avatarAttackDamageProperties(avatar *goproto.AvatarBattleInfo) []*goproto.AttackDamageProperty {
|
|
if avatar == nil {
|
|
return nil
|
|
}
|
|
return avatar.AttackTypeDamage
|
|
}
|
|
|
|
func sumAttackDamageProperties(properties []*goproto.AttackDamageProperty) float64 {
|
|
var total float64
|
|
for _, property := range properties {
|
|
if property != nil && property.Damage > 0 {
|
|
total += property.Damage
|
|
}
|
|
}
|
|
return total
|
|
}
|
|
|
|
// SaveBattleReport writes the battle report to disk atomically.
|
|
func SaveBattleReport(report *BattleReport) error {
|
|
if report == nil {
|
|
return nil
|
|
}
|
|
battleReportMu.Lock()
|
|
defer battleReportMu.Unlock()
|
|
return writeJSONAtomic(BattleReportFile, report)
|
|
}
|
|
|
|
// LoadLatestBattleReport loads the most recent battle report from disk.
|
|
func LoadLatestBattleReport() (*BattleReport, error) {
|
|
battleReportMu.RLock()
|
|
defer battleReportMu.RUnlock()
|
|
raw, err := os.ReadFile(BattleReportFile)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var report BattleReport
|
|
if err := json.Unmarshal(raw, &report); err != nil {
|
|
return nil, err
|
|
}
|
|
return &report, nil
|
|
}
|
|
|
|
// ClearBattleReport deletes the current battle report file.
|
|
func ClearBattleReport() error {
|
|
battleReportMu.Lock()
|
|
defer battleReportMu.Unlock()
|
|
_ = os.Remove(BattleReportFile)
|
|
return nil
|
|
}
|