MosswartOverlord/go-services/tracker-go/combat.go
Erik 7350b00341 feat(go-services): Phase 2 — combat_stats accumulator (cross-language exact)
Ports main.py's _combat_session_delta / _combat_merge_into_lifetime (incl. the
documented "offense/defense use latest, additively" quirk) and the combat_stats
handler (session delta -> DB-backed lifetime merge -> delete-then-insert of
combat_stats + combat_stats_sessions). Read handlers gain the live combat
overlay (union live + DB), like Python.

Validation:
- combat.go `combat-merge` CLI folds snapshots through the accumulator; diffed
  against the Python functions on identical input -> byte-IDENTICAL.
- combat_test.go golden test runs in the build (go test now part of the tracker
  Dockerfile).
- Live: 40 combat lifetime rows + 40 session snapshots + rare_events flowing in
  dereth_go via the shadow consumer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 10:42:15 +02:00

242 lines
8.5 KiB
Go

package main
import (
"context"
"encoding/json"
"io"
"os"
"time"
)
// runCombatMergeCLI folds a JSON array of cumulative session snapshots (stdin)
// through combatSessionDelta + combatMergeIntoLifetime and prints the resulting
// lifetime, mirroring exactly what the combat_stats handler accumulates. Used to
// diff the Go accumulator against the Python functions on identical input.
func runCombatMergeCLI() {
raw, _ := io.ReadAll(os.Stdin)
var snapshots []map[string]any
if err := json.Unmarshal(raw, &snapshots); err != nil {
os.Stderr.WriteString("combat-merge: invalid JSON: " + err.Error() + "\n")
os.Exit(1)
}
lifetime := map[string]any{}
var last map[string]any
for _, s := range snapshots {
var delta map[string]any
if last != nil {
delta = combatSessionDelta(s, last)
} else {
delta = s
}
lifetime = combatMergeIntoLifetime(lifetime, delta)
last = s
}
out, _ := json.Marshal(lifetime)
os.Stdout.Write(out)
}
// Combat stats accumulation — a faithful port of main.py's
// _combat_session_delta / _combat_merge_into_lifetime (incl. the documented
// quirk that offense/defense use the latest snapshot rather than a true delta).
// JSON numbers decode to float64; Go marshals whole floats without a decimal,
// so the stored JSONB matches Python's integer output.
func num(v any) float64 {
switch x := v.(type) {
case float64:
return x
case int:
return float64(x)
case int64:
return float64(x)
}
return 0
}
func asMap(v any) map[string]any {
if m, ok := v.(map[string]any); ok {
return m
}
return map[string]any{}
}
func combatSessionDelta(newS, oldS map[string]any) map[string]any {
delta := map[string]any{
"total_damage_given": num(newS["total_damage_given"]) - num(oldS["total_damage_given"]),
"total_damage_received": num(newS["total_damage_received"]) - num(oldS["total_damage_received"]),
"total_kills": num(newS["total_kills"]) - num(oldS["total_kills"]),
"total_aetheria_surges": num(newS["total_aetheria_surges"]) - num(oldS["total_aetheria_surges"]),
"total_cloak_surges": num(newS["total_cloak_surges"]) - num(oldS["total_cloak_surges"]),
"monsters": map[string]any{},
}
newMonsters := asMap(newS["monsters"])
oldMonsters := asMap(oldS["monsters"])
dMonsters := delta["monsters"].(map[string]any)
for name, nmv := range newMonsters {
nm := asMap(nmv)
om := asMap(oldMonsters[name])
dm := map[string]any{
"name": name,
"kill_count": num(nm["kill_count"]) - num(om["kill_count"]),
"damage_given": num(nm["damage_given"]) - num(om["damage_given"]),
"damage_received": num(nm["damage_received"]) - num(om["damage_received"]),
"aetheria_surges": num(nm["aetheria_surges"]) - num(om["aetheria_surges"]),
"cloak_surges": num(nm["cloak_surges"]) - num(om["cloak_surges"]),
"offense": asMap(nm["offense"]), // latest snapshot, per main.py
"defense": asMap(nm["defense"]),
}
if num(dm["kill_count"]) > 0 || num(dm["damage_given"]) > 0 || num(dm["damage_received"]) > 0 {
dMonsters[name] = dm
}
}
return delta
}
func combatMergeIntoLifetime(lifetime, delta map[string]any) map[string]any {
if len(lifetime) == 0 {
lifetime = map[string]any{
"total_damage_given": float64(0), "total_damage_received": float64(0),
"total_kills": float64(0), "total_aetheria_surges": float64(0),
"total_cloak_surges": float64(0), "monsters": map[string]any{},
}
}
lifetime["total_damage_given"] = num(lifetime["total_damage_given"]) + num(delta["total_damage_given"])
lifetime["total_damage_received"] = num(lifetime["total_damage_received"]) + num(delta["total_damage_received"])
lifetime["total_kills"] = num(lifetime["total_kills"]) + num(delta["total_kills"])
lifetime["total_aetheria_surges"] = num(lifetime["total_aetheria_surges"]) + num(delta["total_aetheria_surges"])
lifetime["total_cloak_surges"] = num(lifetime["total_cloak_surges"]) + num(delta["total_cloak_surges"])
ltMonsters := asMap(lifetime["monsters"])
lifetime["monsters"] = ltMonsters
for name, dmv := range asMap(delta["monsters"]) {
dm := asMap(dmv)
lmv, ok := ltMonsters[name]
if !ok {
lmv = map[string]any{
"name": name, "kill_count": float64(0), "damage_given": float64(0),
"damage_received": float64(0), "aetheria_surges": float64(0),
"cloak_surges": float64(0), "offense": map[string]any{}, "defense": map[string]any{},
}
ltMonsters[name] = lmv
}
lm := asMap(lmv)
lm["kill_count"] = num(lm["kill_count"]) + num(dm["kill_count"])
lm["damage_given"] = num(lm["damage_given"]) + num(dm["damage_given"])
lm["damage_received"] = num(lm["damage_received"]) + num(dm["damage_received"])
lm["aetheria_surges"] = num(lm["aetheria_surges"]) + num(dm["aetheria_surges"])
lm["cloak_surges"] = num(lm["cloak_surges"]) + num(dm["cloak_surges"])
for _, side := range []string{"offense", "defense"} {
deltaSide := asMap(dm[side])
if len(deltaSide) == 0 {
continue
}
ltSide := asMap(lm[side])
lm[side] = ltSide
for atkType, byElV := range deltaSide {
ltByEl := asMap(ltSide[atkType])
ltSide[atkType] = ltByEl
for el, statsV := range asMap(byElV) {
stats := asMap(statsV)
ltS := asMap(ltByEl[el])
if len(ltS) == 0 {
ltS = map[string]any{
"total_attacks": float64(0), "failed_attacks": float64(0), "crits": float64(0),
"total_normal_damage": float64(0), "max_normal_damage": float64(0),
"total_crit_damage": float64(0), "max_crit_damage": float64(0),
}
}
ltByEl[el] = ltS
ltS["total_attacks"] = num(ltS["total_attacks"]) + num(stats["total_attacks"])
ltS["failed_attacks"] = num(ltS["failed_attacks"]) + num(stats["failed_attacks"])
ltS["crits"] = num(ltS["crits"]) + num(stats["crits"])
ltS["total_normal_damage"] = num(ltS["total_normal_damage"]) + num(stats["total_normal_damage"])
ltS["max_normal_damage"] = maxF(num(ltS["max_normal_damage"]), num(stats["max_normal_damage"]))
ltS["total_crit_damage"] = num(ltS["total_crit_damage"]) + num(stats["total_crit_damage"])
ltS["max_crit_damage"] = maxF(num(ltS["max_crit_damage"]), num(stats["max_crit_damage"]))
}
}
}
}
return lifetime
}
func maxF(a, b float64) float64 {
if a > b {
return a
}
return b
}
// handleCombatStats mirrors main.py:3305: compute the session delta vs the last
// snapshot, merge into the (DB-backed) lifetime, persist lifetime + the session
// snapshot (delete-then-insert), and update the live overlay.
func (i *Ingestor) handleCombatStats(ctx context.Context, data map[string]any) {
char := toStr(data["character_name"])
if char == "" {
return
}
sessionV, hasSession := data["session"]
sessionID := toStr(data["session_id"])
if hasSession && sessionV != nil {
sessionData := asMap(sessionV)
prevKey := char + ":" + sessionID
i.mu.Lock()
prev, hadPrev := i.combatLastSession[prevKey]
i.combatLastSession[prevKey] = sessionData
i.mu.Unlock()
var delta map[string]any
if hadPrev {
delta = combatSessionDelta(sessionData, prev)
} else {
delta = sessionData
}
// Load lifetime from cache, else DB (else empty).
i.mu.Lock()
lifetime, cached := i.combatLifetimeCache[char]
i.mu.Unlock()
if !cached {
lifetime = map[string]any{}
if row, err := queryRowAsMap(ctx, i.pool,
`SELECT stats_data FROM combat_stats WHERE character_name=$1`, char); err == nil && row != nil {
if m := asJSONMap(row["stats_data"]); m != nil {
lifetime = m
}
}
}
lifetime = combatMergeIntoLifetime(lifetime, delta)
i.mu.Lock()
i.combatLifetimeCache[char] = lifetime
i.mu.Unlock()
now := time.Now().UTC()
ltJSON, _ := json.Marshal(lifetime)
// delete-then-insert lifetime (no ON CONFLICT, matching Python)
if _, err := i.pool.Exec(ctx, `DELETE FROM combat_stats WHERE character_name=$1`, char); err == nil {
if _, err := i.pool.Exec(ctx,
`INSERT INTO combat_stats (character_name,timestamp,stats_data) VALUES ($1,$2,$3)`,
char, now, ltJSON); err != nil {
i.log.Error("combat_stats insert failed", "err", err, "char", char)
}
}
if sessionID != "" {
sdJSON, _ := json.Marshal(sessionData)
if _, err := i.pool.Exec(ctx,
`DELETE FROM combat_stats_sessions WHERE character_name=$1 AND session_id=$2`, char, sessionID); err == nil {
if _, err := i.pool.Exec(ctx,
`INSERT INTO combat_stats_sessions (character_name,session_id,timestamp,stats_data) VALUES ($1,$2,$3,$4)`,
char, sessionID, now, sdJSON); err != nil {
i.log.Error("combat_stats_sessions insert failed", "err", err, "char", char)
}
}
}
data["lifetime"] = lifetime
}
i.mu.Lock()
i.liveCombatStats[char] = data
i.mu.Unlock()
}