Files
GaCy-GO/internal/common/battle_report.go
T

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
}