2025-08-11 15:36:56 +02:00

514 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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))
}