feat: capture in-game battle damage statistics & add battle_report API endpoints
This commit is contained in:
1 parent
5c4d0d4149
commit
365e66ffe0
5 files changed
+415
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
|
||||
@@ -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",
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user