feat: capture in-game battle damage statistics & add battle_report API endpoints

This commit is contained in:
CAtlas0784 committed 2026-10-17 13:08:13 +07:00
1 parent 5c4d0d4149
commit 365e66ffe0
5 files changed
+415

No files matched your search

+219
View File
@@ -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
}
+103
View File
@@ -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")
}
}
+17
View File
@@ -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
})
+12
View File
@@ -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
})
+64
View File
@@ -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",
})
}