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 }