diff --git a/internal/common/battle_report.go b/internal/common/battle_report.go new file mode 100644 index 0000000..c325911 --- /dev/null +++ b/internal/common/battle_report.go @@ -0,0 +1,219 @@ +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)), + } + + var teamDmg, teamHeal, teamShield float64 + for _, a := range stt.BattleAvatarList { + if a == nil { + continue + } + teamDmg += a.TotalDamage + teamHeal += a.TotalHeal + teamShield += a.TotalShield + } + report.TeamTotalDamage = teamDmg + report.TeamTotalHeal = teamHeal + report.TeamTotalShield = teamShield + + for _, a := range stt.BattleAvatarList { + if a == nil { + continue + } + var dmgPercent float64 + if teamDmg > 0 { + dmgPercent = (a.TotalDamage / teamDmg) * 100.0 + } + ar := &BattleAvatarReport{ + AvatarID: a.Id, + AvatarLevel: a.AvatarLevel, + AvatarRank: a.AvatarRank, + TotalDamage: a.TotalDamage, + 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 +} diff --git a/internal/common/battle_report_test.go b/internal/common/battle_report_test.go new file mode 100644 index 0000000..ac407a8 --- /dev/null +++ b/internal/common/battle_report_test.go @@ -0,0 +1,103 @@ +package common + +import ( + "os" + "testing" + + goproto "gac-hkrpg-go/protocol/proto" +) + +func TestBattleReportSerialization(t *testing.T) { + defer os.Remove(BattleReportFile) + + stt := &goproto.BattleStatistics{ + TotalBattleTurns: 12, + TotalAutoTurns: 0, + RoundCnt: 2, + CostTime: 45.5, + ChallengeScore: 1000, + BattleAvatarList: []*goproto.AvatarBattleInfo{ + { + Id: 1310, + AvatarLevel: 80, + AvatarRank: 2, + TotalDamage: 500000, + TotalDamageTaken: 2500, + TotalHeal: 0, + TotalBreakDamage: 400000, + TotalTurns: 6, + AttackTypeDamage: []*goproto.AttackDamageProperty{ + {AttackType: "Normal", Damage: 20000}, + {AttackType: "BPSkill", Damage: 80000}, + {AttackType: "BreakDamage", Damage: 400000}, + }, + AttackTypeMaxDamage: []*goproto.AttackDamageProperty{ + {AttackType: "BreakDamage", Damage: 250000}, + }, + SkillTimes: []*goproto.SkillUseProperty{ + {SkillId: 131001, SkillType: "Normal", SkillUseCount: 1}, + {SkillId: 131002, SkillType: "BPSkill", SkillUseCount: 4}, + }, + }, + { + Id: 1001, + AvatarLevel: 80, + AvatarRank: 6, + TotalDamage: 50000, + TotalDamageTaken: 1200, + TotalShield: 4500, + TotalTurns: 6, + AttackTypeDamage: []*goproto.AttackDamageProperty{ + {AttackType: "Normal", Damage: 50000}, + }, + }, + }, + MonsterList: []*goproto.MonsterBattleInfo{ + { + EntityId: 101, + MonsterId: 3024010, + TotalTurns: 3, + TotalDamageTaken: 550000, + TotalDamage: 3700, + Wave: 1, + }, + }, + } + + report := BattleReportFromStatistics(stt, 999, 1001, "WIN") + if report == nil { + t.Fatal("expected report, got nil") + } + if report.TeamTotalDamage != 550000 { + t.Fatalf("expected team total damage 550000, got %f", report.TeamTotalDamage) + } + if len(report.Avatars) != 2 { + t.Fatalf("expected 2 avatars, got %d", len(report.Avatars)) + } + if report.Avatars[0].DamagePercent < 90 || report.Avatars[0].DamagePercent > 91 { + t.Fatalf("unexpected damage percent %f", report.Avatars[0].DamagePercent) + } + + if err := SaveBattleReport(report); err != nil { + t.Fatalf("failed to save report: %v", err) + } + + loaded, err := LoadLatestBattleReport() + if err != nil { + t.Fatalf("failed to load report: %v", err) + } + if loaded.BattleID != 999 { + t.Fatalf("expected battle id 999, got %d", loaded.BattleID) + } + if loaded.TeamTotalDamage != 550000 { + t.Fatalf("expected team damage 550000, got %f", loaded.TeamTotalDamage) + } + + if err := ClearBattleReport(); err != nil { + t.Fatalf("failed to clear report: %v", err) + } + _, err = LoadLatestBattleReport() + if err == nil { + t.Fatal("expected error after clearing report, got nil") + } +} diff --git a/internal/gameserver/handlers_battle.go b/internal/gameserver/handlers_battle.go index e62b332..6bb6eda 100644 --- a/internal/gameserver/handlers_battle.go +++ b/internal/gameserver/handlers_battle.go @@ -113,6 +113,23 @@ func init() { rsp.EndStatus = req.EndStatus rsp.BattleId = req.BattleId rsp.StageId = req.StageId + + if req.Stt != nil { + statusStr := "WIN" + switch req.EndStatus { + case goproto.BattleEndStatus_BATTLE_END_LOSE: + statusStr = "LOSE" + case goproto.BattleEndStatus_BATTLE_END_QUIT: + statusStr = "QUIT" + } + report := common.BattleReportFromStatistics(req.Stt, req.BattleId, req.StageId, statusStr) + if err := common.SaveBattleReport(report); err != nil { + Log(fmt.Sprintf("[DAMAGE_ANALYST] Failed to save battle report: %v", err)) + } else { + Log(fmt.Sprintf("[DAMAGE_ANALYST] Battle report captured: BattleID=%d Dmg=%.1f Cycles=%d Turns=%d Status=%s", + req.BattleId, report.TeamTotalDamage, report.RoundCnt, report.TotalBattleTurns, statusStr)) + } + } return nil }) diff --git a/internal/gameserver/handlers_ui.go b/internal/gameserver/handlers_ui.go index 50034a9..4cfa46f 100644 --- a/internal/gameserver/handlers_ui.go +++ b/internal/gameserver/handlers_ui.go @@ -1,6 +1,9 @@ package gameserver import ( + "fmt" + + "gac-hkrpg-go/internal/common" goproto "gac-hkrpg-go/protocol/proto" ) @@ -33,6 +36,15 @@ func init() { handle("QuitBattle", func(s *Session, req *goproto.QuitBattleCsReq, rsp *goproto.QuitBattleScRsp) error { rsp.Retcode = 0 + if req.Stt != nil { + report := common.BattleReportFromStatistics(req.Stt, 0, 0, "QUIT") + if err := common.SaveBattleReport(report); err != nil { + Log(fmt.Sprintf("[DAMAGE_ANALYST] Failed to save quit battle report: %v", err)) + } else { + Log(fmt.Sprintf("[DAMAGE_ANALYST] Quit battle report captured: Dmg=%.1f Cycles=%d Turns=%d", + report.TeamTotalDamage, report.RoundCnt, report.TotalBattleTurns)) + } + } return nil }) diff --git a/internal/sdkserver/api.go b/internal/sdkserver/api.go index 2176d66..2364fd9 100644 --- a/internal/sdkserver/api.go +++ b/internal/sdkserver/api.go @@ -48,6 +48,8 @@ func registerAPIRoutes(mux *http.ServeMux) { mux.HandleFunc("/api/gachabase/fetch", corsWrapper(apiGachabaseFetch)) mux.HandleFunc("/api/command", corsWrapper(apiCommand)) mux.HandleFunc("/api/data-in-game", corsWrapper(apiDataInGame)) + mux.HandleFunc("/api/battle_report", corsWrapper(apiBattleReport)) + mux.HandleFunc("/api/battle_report/reset", corsWrapper(apiBattleReportReset)) } func apiRoot(w http.ResponseWriter, r *http.Request) { @@ -76,6 +78,7 @@ func apiRoot(w http.ResponseWriter, r *http.Request) { "avatar_max_all": "POST /api/avatar/max_all", "teleport": "POST /api/teleport", "command": "POST /api/command", + "battle_report": "GET, POST /api/battle_report", }, }) } @@ -771,3 +774,64 @@ func apiCommand(w http.ResponseWriter, r *http.Request) { }) } +// apiBattleReport handles GET (fetch latest in-game battle) and POST (import battle report) +func apiBattleReport(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + report, err := common.LoadLatestBattleReport() + if err != nil { + writeJSON(w, map[string]any{ + "status": 200, + "has_report": false, + "message": "No battle report recorded yet", + }) + return + } + writeJSON(w, map[string]any{ + "status": 200, + "has_report": true, + "report": report, + }) + return + } + + if r.Method == http.MethodPost { + var report common.BattleReport + if err := json.NewDecoder(r.Body).Decode(&report); err != nil { + writeJSON(w, map[string]any{"error": "invalid json: " + err.Error(), "status": 400}) + return + } + if report.Timestamp == 0 { + report.Timestamp = time.Now().Unix() + } + if err := common.SaveBattleReport(&report); err != nil { + writeJSON(w, map[string]any{"error": err.Error(), "status": 500}) + return + } + writeJSON(w, map[string]any{ + "status": 200, + "message": "Battle report saved successfully", + "report": report, + }) + return + } + + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) +} + +// apiBattleReportReset clears the current recorded battle report +func apiBattleReportReset(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost && r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + if err := common.ClearBattleReport(); err != nil { + writeJSON(w, map[string]any{"error": err.Error(), "status": 500}) + return + } + writeJSON(w, map[string]any{ + "status": 200, + "message": "Battle report cleared", + }) +} + +