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