514 lines
13 KiB
Go
514 lines
13 KiB
Go
package main
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"math"
|
||
"os"
|
||
|
||
demoinfocs "github.com/markus-wa/demoinfocs-golang/v4/pkg/demoinfocs"
|
||
"github.com/markus-wa/demoinfocs-golang/v4/pkg/demoinfocs/common"
|
||
"github.com/markus-wa/demoinfocs-golang/v4/pkg/demoinfocs/events"
|
||
msg "github.com/markus-wa/demoinfocs-golang/v4/pkg/demoinfocs/msgs2"
|
||
)
|
||
|
||
type Team struct {
|
||
Name string `json:"name"`
|
||
Score int `json:"score"`
|
||
Players []PlayerStats `json:"players"`
|
||
}
|
||
|
||
type PlayerStats struct {
|
||
Name string `json:"name"`
|
||
SteamID string `json:"steamId"`
|
||
Team string `json:"team"`
|
||
Kills int `json:"kills"`
|
||
Deaths int `json:"deaths"`
|
||
Assists int `json:"assists"`
|
||
FlashAssists int `json:"flashAssists"`
|
||
TotalDamage int `json:"totalDamage"`
|
||
UtilityDamage int `json:"utilityDamage"`
|
||
MVPs int `json:"mvps"`
|
||
MVPReason1 int `json:"mvpEliminations"`
|
||
MVPReason2 int `json:"mvpDefuse"`
|
||
MVPReason3 int `json:"mvpPlant"`
|
||
KnifeKills int `json:"knifeKills"`
|
||
ZeusKills int `json:"zeusKills"`
|
||
WallbangKills int `json:"wallbangKills"`
|
||
SmokeKills int `json:"smokeKills"`
|
||
Headshots int `json:"headshots"`
|
||
NoScopes int `json:"noScopes"`
|
||
BlindKills int `json:"blindKills"`
|
||
RankOld int `json:"rankOld,omitempty"`
|
||
RankNew int `json:"rankNew,omitempty"`
|
||
RankChange int `json:"rankChange,omitempty"`
|
||
WinCount int `json:"winCount,omitempty"`
|
||
OneK int `json:"oneK"`
|
||
TwoK int `json:"twoK"`
|
||
ThreeK int `json:"threeK"`
|
||
FourK int `json:"fourK"`
|
||
FiveK int `json:"fiveK"`
|
||
ShotsFired int `json:"shotsFired"`
|
||
ShotsHit int `json:"shotsHit"`
|
||
Aim float64 `json:"aim"` // Prozent
|
||
}
|
||
|
||
type RoundResult struct {
|
||
Round int `json:"round"`
|
||
Winner string `json:"winner"`
|
||
WinReason string `json:"winReason"`
|
||
}
|
||
|
||
type PlayerTeamHistory map[int]map[uint64]string
|
||
|
||
type DemoMeta struct {
|
||
MatchID uint64 `json:"matchId"`
|
||
Map string `json:"map"`
|
||
Duration float64 `json:"duration"`
|
||
TickRate float64 `json:"tickRate"`
|
||
WinnerTeam string `json:"winnerTeam"`
|
||
RoundCount int `json:"roundCount"`
|
||
RoundHistory []RoundResult `json:"roundHistory"`
|
||
TeamA Team `json:"teamA"`
|
||
TeamB Team `json:"teamB"`
|
||
}
|
||
|
||
func sanitizeFloat(value float64) float64 {
|
||
if math.IsNaN(value) || math.IsInf(value, 0) {
|
||
return 0
|
||
}
|
||
return value
|
||
}
|
||
|
||
func reasonToString(reason events.RoundEndReason) string {
|
||
switch reason {
|
||
case events.RoundEndReasonTargetBombed:
|
||
return "Bomb"
|
||
case events.RoundEndReasonTerroristsStopped:
|
||
return "T eliminated"
|
||
case events.RoundEndReasonCTStoppedEscape:
|
||
return "CT stopped escape"
|
||
case events.RoundEndReasonTerroristsEscaped:
|
||
return "T escaped"
|
||
case events.RoundEndReasonHostagesRescued:
|
||
return "Hostages rescued"
|
||
case events.RoundEndReasonCTWin:
|
||
return "CT win"
|
||
case events.RoundEndReasonTerroristsWin:
|
||
return "T win"
|
||
case events.RoundEndReasonDraw:
|
||
return "Draw"
|
||
case events.RoundEndReasonHostagesNotRescued:
|
||
return "Hostages not rescued"
|
||
case events.RoundEndReasonTerroristsNotEscaped:
|
||
return "T not escaped"
|
||
case events.RoundEndReasonBombDefused:
|
||
return "Bomb defused"
|
||
default:
|
||
return "Unknown"
|
||
}
|
||
}
|
||
|
||
func parseSteamID(id string) (uint64, error) {
|
||
var sid uint64
|
||
_, err := fmt.Sscanf(id, "%d", &sid)
|
||
return sid, err
|
||
}
|
||
|
||
// Bullet-Waffen (Schusswaffen) erkennen – keine Nades/Knife/Zeus/Equipment
|
||
func isBulletWeapon(eq *common.Equipment) bool {
|
||
if eq == nil {
|
||
return false
|
||
}
|
||
switch eq.Type {
|
||
case common.EqKnife, common.EqZeus, common.EqHE, common.EqMolotov, common.EqIncendiary, common.EqFlash, common.EqSmoke, common.EqDecoy:
|
||
return false
|
||
}
|
||
cls := eq.Class()
|
||
if cls == common.EqClassGrenade || cls == common.EqClassEquipment {
|
||
return false
|
||
}
|
||
return true // Pistols/SMG/Rifles/Heavy/Sniper
|
||
}
|
||
|
||
func main() {
|
||
if len(os.Args) < 2 {
|
||
fmt.Println("❌ Demo-Datei fehlt")
|
||
os.Exit(1)
|
||
}
|
||
|
||
filePath := os.Args[1]
|
||
f, err := os.Open(filePath)
|
||
if err != nil {
|
||
fmt.Printf("❌ Datei konnte nicht geöffnet werden: %v\n", err)
|
||
os.Exit(1)
|
||
}
|
||
defer f.Close()
|
||
|
||
p := demoinfocs.NewParser(f)
|
||
|
||
var mapName string
|
||
var matchId uint64
|
||
var scoreCT, scoreT, roundCount int
|
||
var roundHistory []RoundResult
|
||
var teamHistory = make(PlayerTeamHistory)
|
||
|
||
// Per-Runde Kills, die bis zum *nächsten* RoundStart gehalten werden
|
||
var roundKills = make(map[uint64]int) // SteamID64 -> Kills in der aktuellen (zuletzt gestarteten) Runde
|
||
var haveOpenRound bool // true ab erstem RoundStart nach MatchStart
|
||
var lastNonSpecTeam = make(map[uint64]string) // "CT" oder "T"
|
||
|
||
if len(os.Args) >= 3 {
|
||
inputId := os.Args[2]
|
||
_, err := fmt.Sscanf(inputId, "%d", &matchId)
|
||
if err != nil {
|
||
fmt.Printf("⚠️ Ungültiges matchId-Argument: %v\n", err)
|
||
matchId = 0
|
||
}
|
||
}
|
||
|
||
p.RegisterNetMessageHandler(func(m *msg.CSVCMsg_ServerInfo) {
|
||
if m != nil && m.GetMapName() != "" {
|
||
mapName = m.GetMapName()
|
||
}
|
||
})
|
||
|
||
header, err := p.ParseHeader()
|
||
if err != nil {
|
||
fmt.Printf("❌ Header konnte nicht gelesen werden: %v\n", err)
|
||
os.Exit(1)
|
||
}
|
||
|
||
playerStats := make(map[uint64]*PlayerStats)
|
||
|
||
getOrCreate := func(player common.Player) *PlayerStats {
|
||
sid := player.SteamID64
|
||
stat := playerStats[sid]
|
||
if stat == nil {
|
||
stat = &PlayerStats{
|
||
Name: player.Name,
|
||
SteamID: fmt.Sprintf("%d", sid),
|
||
Team: "",
|
||
}
|
||
playerStats[sid] = stat
|
||
}
|
||
return stat
|
||
}
|
||
|
||
// Hilfsfunktion: roundKills -> 1k/2k/3k/4k/5k übertragen
|
||
flushRoundKills := func() {
|
||
for sid, k := range roundKills {
|
||
stat := playerStats[sid]
|
||
if stat == nil {
|
||
continue
|
||
}
|
||
switch {
|
||
case k == 1:
|
||
stat.OneK++
|
||
case k == 2:
|
||
stat.TwoK++
|
||
case k == 3:
|
||
stat.ThreeK++
|
||
case k == 4:
|
||
stat.FourK++
|
||
case k >= 5:
|
||
stat.FiveK++
|
||
}
|
||
}
|
||
roundKills = make(map[uint64]int)
|
||
}
|
||
|
||
p.RegisterEventHandler(func(e events.RoundStart) {
|
||
if !p.GameState().IsMatchStarted() {
|
||
return
|
||
}
|
||
|
||
if haveOpenRound {
|
||
flushRoundKills()
|
||
}
|
||
haveOpenRound = true
|
||
|
||
round := roundCount + 1
|
||
teamHistory[round] = map[uint64]string{}
|
||
for _, pl := range p.GameState().Participants().Playing() {
|
||
switch pl.Team {
|
||
case common.TeamCounterTerrorists:
|
||
teamHistory[round][pl.SteamID64] = "CT"
|
||
lastNonSpecTeam[pl.SteamID64] = "CT"
|
||
case common.TeamTerrorists:
|
||
teamHistory[round][pl.SteamID64] = "T"
|
||
lastNonSpecTeam[pl.SteamID64] = "T"
|
||
}
|
||
}
|
||
})
|
||
|
||
p.RegisterEventHandler(func(e events.PlayerTeamChange) {
|
||
if e.Player == nil {
|
||
return
|
||
}
|
||
sid := e.Player.SteamID64
|
||
// Nur Nicht-Spectator übernehmen; Spectator/Unassigned löscht NICHT den letzten Stand
|
||
switch e.NewTeam {
|
||
case common.TeamCounterTerrorists:
|
||
lastNonSpecTeam[sid] = "CT"
|
||
case common.TeamTerrorists:
|
||
lastNonSpecTeam[sid] = "T"
|
||
}
|
||
})
|
||
|
||
// Schüsse zählen (nur Schusswaffen)
|
||
p.RegisterEventHandler(func(e events.WeaponFire) {
|
||
if !haveOpenRound {
|
||
return // Warmup ignorieren
|
||
}
|
||
if e.Shooter == nil || e.Weapon == nil {
|
||
return
|
||
}
|
||
if !isBulletWeapon(e.Weapon) {
|
||
return
|
||
}
|
||
getOrCreate(*e.Shooter).ShotsFired++
|
||
})
|
||
|
||
p.RegisterEventHandler(func(e events.Kill) {
|
||
if e.Killer != nil && e.Victim != nil && e.Killer.SteamID64 != e.Victim.SteamID64 {
|
||
killerTeam := e.Killer.Team
|
||
victimTeam := e.Victim.Team
|
||
|
||
if killerTeam != victimTeam && killerTeam != common.TeamSpectators {
|
||
stat := getOrCreate(*e.Killer)
|
||
stat.Kills++
|
||
|
||
// Nur zählen, wenn wir uns in/zwischen echten Runden befinden
|
||
if haveOpenRound {
|
||
roundKills[e.Killer.SteamID64]++
|
||
}
|
||
|
||
if e.IsHeadshot {
|
||
stat.Headshots++
|
||
}
|
||
if e.NoScope {
|
||
stat.NoScopes++
|
||
}
|
||
if e.AttackerBlind {
|
||
stat.BlindKills++
|
||
}
|
||
if e.ThroughSmoke {
|
||
stat.SmokeKills++
|
||
}
|
||
if e.IsWallBang() {
|
||
stat.WallbangKills++
|
||
}
|
||
if e.Weapon != nil {
|
||
switch e.Weapon.Type {
|
||
case common.EqKnife:
|
||
stat.KnifeKills++
|
||
case common.EqZeus:
|
||
stat.ZeusKills++
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if e.Victim != nil {
|
||
getOrCreate(*e.Victim).Deaths++
|
||
}
|
||
if e.Assister != nil {
|
||
getOrCreate(*e.Assister).Assists++
|
||
}
|
||
})
|
||
|
||
p.RegisterEventHandler(func(e events.PlayerFlashed) {
|
||
if e.Attacker != nil && e.Attacker.SteamID64 != e.Player.SteamID64 {
|
||
getOrCreate(*e.Attacker).FlashAssists++
|
||
}
|
||
})
|
||
|
||
p.RegisterEventHandler(func(e events.PlayerHurt) {
|
||
if e.Attacker != nil && e.Attacker != e.Player {
|
||
stat := getOrCreate(*e.Attacker)
|
||
|
||
// Utility-Damage separat zählen
|
||
if e.Weapon != nil {
|
||
switch e.Weapon.Type {
|
||
case common.EqHE, common.EqMolotov, common.EqIncendiary:
|
||
stat.UtilityDamage += e.HealthDamage
|
||
}
|
||
}
|
||
|
||
// Treffer (nur Schusswaffen, nur Gegner, nur echte Runden)
|
||
if haveOpenRound && e.HealthDamage > 0 && e.Weapon != nil && isBulletWeapon(e.Weapon) {
|
||
attTeam := e.Attacker.Team
|
||
vicTeam := e.Player.Team
|
||
if attTeam != vicTeam && attTeam != common.TeamSpectators {
|
||
stat.ShotsHit++
|
||
}
|
||
}
|
||
}
|
||
})
|
||
|
||
p.RegisterEventHandler(func(e events.RoundMVPAnnouncement) {
|
||
if e.Player != nil {
|
||
stat := getOrCreate(*e.Player)
|
||
stat.MVPs++
|
||
switch e.Reason {
|
||
case events.MVPReasonMostEliminations:
|
||
stat.MVPReason1++
|
||
case events.MVPReasonBombDefused:
|
||
stat.MVPReason2++
|
||
case events.MVPReasonBombPlanted:
|
||
stat.MVPReason3++
|
||
}
|
||
}
|
||
})
|
||
|
||
p.RegisterEventHandler(func(e events.RoundEnd) {
|
||
// Scores & Round-History wie gehabt
|
||
scoreCT = p.GameState().TeamCounterTerrorists().Score()
|
||
scoreT = p.GameState().TeamTerrorists().Score()
|
||
roundCount++
|
||
|
||
var winner string
|
||
switch e.Winner {
|
||
case common.TeamTerrorists:
|
||
winner = "T"
|
||
case common.TeamCounterTerrorists:
|
||
winner = "CT"
|
||
default:
|
||
winner = "Unknown"
|
||
}
|
||
|
||
roundHistory = append(roundHistory, RoundResult{
|
||
Round: roundCount,
|
||
Winner: winner,
|
||
WinReason: reasonToString(e.Reason),
|
||
})
|
||
// KEIN flush hier! Nach-Runden-Kills sollen noch in diese Runde fallen.
|
||
})
|
||
|
||
p.RegisterEventHandler(func(e events.RankUpdate) {
|
||
if e.Player != nil {
|
||
stat := getOrCreate(*e.Player)
|
||
stat.RankOld = e.RankOld
|
||
stat.RankNew = e.RankNew
|
||
stat.RankChange = int(e.RankChange)
|
||
stat.WinCount = e.WinCount
|
||
}
|
||
})
|
||
|
||
err = p.ParseToEnd()
|
||
if err != nil {
|
||
fmt.Printf("❌ Fehler beim Parsen: %v\n", err)
|
||
os.Exit(1)
|
||
}
|
||
|
||
// Letztes Flush falls Match endet ohne weiteren RoundStart
|
||
if haveOpenRound && len(roundKills) > 0 {
|
||
flushRoundKills()
|
||
}
|
||
|
||
teamAName := p.GameState().TeamCounterTerrorists().ClanName()
|
||
if teamAName == "" {
|
||
teamAName = "CT"
|
||
}
|
||
teamBName := p.GameState().TeamTerrorists().ClanName()
|
||
if teamBName == "" {
|
||
teamBName = "T"
|
||
}
|
||
|
||
for _, stat := range playerStats {
|
||
sid, _ := parseSteamID(stat.SteamID)
|
||
|
||
if t, ok := lastNonSpecTeam[sid]; ok && (t == "CT" || t == "T") {
|
||
stat.Team = t
|
||
continue
|
||
}
|
||
|
||
// Fallback: letzter Runden-Eintrag aus teamHistory
|
||
lastTeam := ""
|
||
lastRound := 0
|
||
for roundNum, roundTeams := range teamHistory {
|
||
if team, ok := roundTeams[sid]; ok && roundNum > lastRound {
|
||
lastTeam = team
|
||
lastRound = roundNum
|
||
}
|
||
}
|
||
stat.Team = lastTeam
|
||
}
|
||
|
||
for _, pl := range p.GameState().Participants().All() {
|
||
sid := pl.SteamID64
|
||
stat, ok := playerStats[sid]
|
||
if !ok || pl.Entity == nil {
|
||
continue
|
||
}
|
||
if val, ok := pl.Entity.PropertyValue("m_pActionTrackingServices.m_iDamage"); ok {
|
||
stat.TotalDamage = val.Int()
|
||
}
|
||
}
|
||
|
||
// Aim-Wert berechnen
|
||
for _, stat := range playerStats {
|
||
if stat.ShotsFired > 0 {
|
||
stat.Aim = sanitizeFloat(float64(stat.ShotsHit) * 100.0 / float64(stat.ShotsFired))
|
||
} else {
|
||
stat.Aim = 0
|
||
}
|
||
}
|
||
|
||
var ctPlayers, tPlayers []PlayerStats
|
||
for _, stat := range playerStats {
|
||
switch stat.Team {
|
||
case "CT":
|
||
ctPlayers = append(ctPlayers, *stat)
|
||
case "T":
|
||
tPlayers = append(tPlayers, *stat)
|
||
}
|
||
}
|
||
|
||
winnerTeam := "Draw"
|
||
if scoreCT > scoreT {
|
||
winnerTeam = teamAName
|
||
} else if scoreT > scoreCT {
|
||
winnerTeam = teamBName
|
||
}
|
||
|
||
duration := sanitizeFloat(header.PlaybackTime.Seconds())
|
||
tickRate := 0.0
|
||
if duration > 0 {
|
||
tickRate = sanitizeFloat(float64(header.PlaybackTicks) / duration)
|
||
}
|
||
|
||
if mapName == "" {
|
||
mapName = header.MapName
|
||
}
|
||
|
||
result := DemoMeta{
|
||
MatchID: matchId,
|
||
Map: mapName,
|
||
Duration: duration,
|
||
TickRate: tickRate,
|
||
WinnerTeam: winnerTeam,
|
||
RoundCount: roundCount,
|
||
RoundHistory: roundHistory,
|
||
TeamA: Team{
|
||
Name: teamAName,
|
||
Score: scoreCT,
|
||
Players: ctPlayers,
|
||
},
|
||
TeamB: Team{
|
||
Name: teamBName,
|
||
Score: scoreT,
|
||
Players: tPlayers,
|
||
},
|
||
}
|
||
|
||
jsonData, err := json.Marshal(result)
|
||
if err != nil {
|
||
fmt.Printf("❌ Fehler beim JSON-Export: %v\n", err)
|
||
os.Exit(1)
|
||
}
|
||
|
||
fmt.Println(string(jsonData))
|
||
}
|