package common import ( "encoding/json" "os" "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"` AvatarLevel uint32 `json:"level"` AvatarRank uint32 `json:"rank"` TotalDamage float64 `json:"total_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"` } 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 } var teamDmg, teamHeal, teamShield float64 for _, a := range stt.BattleAvatarList { if a == nil { continue } damage := a.TotalDamage if damage == 0 { damage = damageByAvatar[a.Id] } teamDmg += damage teamHeal += a.TotalHeal teamShield += a.TotalShield } // 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] } 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 a.AttackTypeDamage { 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) } return report } // 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 }