From 7350b00341673c4f4d84de7600d0beee2ecd00a6 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 24 Jun 2026 10:42:15 +0200 Subject: [PATCH] =?UTF-8?q?feat(go-services):=20Phase=202=20=E2=80=94=20co?= =?UTF-8?q?mbat=5Fstats=20accumulator=20(cross-language=20exact)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- go-services/tracker-go/Dockerfile | 1 + go-services/tracker-go/charstats.go | 22 ++- go-services/tracker-go/combat.go | 242 ++++++++++++++++++++++++++ go-services/tracker-go/combat_test.go | 54 ++++++ go-services/tracker-go/ingest.go | 24 ++- go-services/tracker-go/main.go | 8 + 6 files changed, 347 insertions(+), 4 deletions(-) create mode 100644 go-services/tracker-go/combat.go create mode 100644 go-services/tracker-go/combat_test.go diff --git a/go-services/tracker-go/Dockerfile b/go-services/tracker-go/Dockerfile index f799af5a..a9032a63 100644 --- a/go-services/tracker-go/Dockerfile +++ b/go-services/tracker-go/Dockerfile @@ -8,6 +8,7 @@ WORKDIR /src # reads the imports from the source and writes go.mod/go.sum, then we build. COPY . . RUN go mod tidy +RUN go test ./... ARG BUILD_VERSION=dev RUN CGO_ENABLED=0 GOOS=linux go build \ -trimpath \ diff --git a/go-services/tracker-go/charstats.go b/go-services/tracker-go/charstats.go index 8376eb16..65e10c59 100644 --- a/go-services/tracker-go/charstats.go +++ b/go-services/tracker-go/charstats.go @@ -43,6 +43,14 @@ func (s *Server) handleCharacterStats(w http.ResponseWriter, r *http.Request) { // so session is always null. (main.py:1819) func (s *Server) handleCombatStatsOne(w http.ResponseWriter, r *http.Request) { cn := r.PathValue("character_name") + if s.ingestor != nil { + if live, ok := s.ingestor.getCombatStats(cn); ok { + writeJSON(w, http.StatusOK, map[string]any{ + "character_name": cn, "session": live["session"], "lifetime": live["lifetime"], + }) + return + } + } ctx, cancel := reqCtx(r) defer cancel() @@ -67,13 +75,25 @@ func (s *Server) handleCombatStatsAll(w http.ResponseWriter, r *http.Request) { ctx, cancel := reqCtx(r) defer cancel() + results := make([]map[string]any, 0) + seen := map[string]bool{} + if s.ingestor != nil { // live overlay first, like Python + for char, live := range s.ingestor.allCombatStats() { + seen[char] = true + results = append(results, map[string]any{ + "character_name": char, "session": live["session"], "lifetime": live["lifetime"], + }) + } + } rows, err := queryRowsAsMaps(ctx, s.pool, `SELECT character_name, stats_data FROM combat_stats`) if err != nil { s.dbErr(w, "combat-stats/all", err) return } - results := make([]map[string]any, 0, len(rows)) for _, row := range rows { + if seen[toStr(row["character_name"])] { + continue + } results = append(results, map[string]any{ "character_name": row["character_name"], "session": nil, diff --git a/go-services/tracker-go/combat.go b/go-services/tracker-go/combat.go new file mode 100644 index 00000000..243b173f --- /dev/null +++ b/go-services/tracker-go/combat.go @@ -0,0 +1,242 @@ +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() +} diff --git a/go-services/tracker-go/combat_test.go b/go-services/tracker-go/combat_test.go new file mode 100644 index 00000000..25bc15cd --- /dev/null +++ b/go-services/tracker-go/combat_test.go @@ -0,0 +1,54 @@ +package main + +import "testing" + +// Golden values cross-checked against the Python _combat_session_delta / +// _combat_merge_into_lifetime on identical input (see compare run). Folds two +// cumulative snapshots; the first is treated as the whole delta. +func TestCombatMerge(t *testing.T) { + snap1 := map[string]any{ + "total_damage_given": 100.0, "total_kills": 2.0, + "monsters": map[string]any{ + "Drudge": map[string]any{ + "name": "Drudge", "kill_count": 2.0, "damage_given": 100.0, + "offense": map[string]any{"melee": map[string]any{"slashing": map[string]any{ + "total_attacks": 10.0, "max_normal_damage": 15.0, + }}}, + }, + }, + } + snap2 := map[string]any{ + "total_damage_given": 250.0, "total_kills": 5.0, + "monsters": map[string]any{ + "Drudge": map[string]any{ + "name": "Drudge", "kill_count": 4.0, "damage_given": 200.0, + "offense": map[string]any{"melee": map[string]any{"slashing": map[string]any{ + "total_attacks": 20.0, "max_normal_damage": 18.0, + }}}, + }, + }, + } + + lifetime := map[string]any{} + lifetime = combatMergeIntoLifetime(lifetime, snap1) // first = whole delta + lifetime = combatMergeIntoLifetime(lifetime, combatSessionDelta(snap2, snap1)) + + if got := num(lifetime["total_kills"]); got != 5 { + t.Errorf("total_kills = %v, want 5", got) + } + if got := num(lifetime["total_damage_given"]); got != 250 { + t.Errorf("total_damage_given = %v, want 250", got) + } + drudge := asMap(asMap(lifetime["monsters"])["Drudge"]) + if got := num(drudge["kill_count"]); got != 4 { + t.Errorf("Drudge.kill_count = %v, want 4", got) + } + slashing := asMap(asMap(asMap(drudge["offense"])["melee"])["slashing"]) + // offense uses the latest snapshot additively (the documented quirk): 10 + 20. + if got := num(slashing["total_attacks"]); got != 30 { + t.Errorf("offense slashing total_attacks = %v, want 30 (latest-additive quirk)", got) + } + if got := num(slashing["max_normal_damage"]); got != 18 { + t.Errorf("offense slashing max_normal_damage = %v, want 18 (max)", got) + } +} diff --git a/go-services/tracker-go/ingest.go b/go-services/tracker-go/ingest.go index 435ba369..1ecc21af 100644 --- a/go-services/tracker-go/ingest.go +++ b/go-services/tracker-go/ingest.go @@ -32,7 +32,9 @@ type Ingestor struct { liveCombatStats map[string]map[string]any dungeonMapCache map[string]map[string]any questStatus map[string]map[string]string - lastKills map[string]int // "session_id|character_name" -> kills + lastKills map[string]int // "session_id|character_name" -> kills + combatLastSession map[string]map[string]any // "char:session_id" -> last cumulative session + combatLifetimeCache map[string]map[string]any // character_name -> accumulated lifetime } func newIngestor(pool *pgxpool.Pool, log *slog.Logger, broadcast func(map[string]any)) *Ingestor { @@ -49,6 +51,8 @@ func newIngestor(pool *pgxpool.Pool, log *slog.Logger, broadcast func(map[string dungeonMapCache: map[string]map[string]any{}, questStatus: map[string]map[string]string{}, lastKills: map[string]int{}, + combatLastSession: map[string]map[string]any{}, + combatLifetimeCache: map[string]map[string]any{}, } } @@ -78,10 +82,12 @@ func (i *Ingestor) dispatch(ctx context.Context, data map[string]any) { i.handleNearbyObjects(data) case t == "dungeon_map": i.handleDungeonMap(data) + case t == "combat_stats": + i.handleCombatStats(ctx, data) case t == "register": // no DB / no broadcast; plugin_conns belongs to the /ws/position server - case t == "combat_stats", strings.HasPrefix(t, "share_"), t == "chat": - // combat_stats + share_* handled in a later pass; chat is broadcast-only + case strings.HasPrefix(t, "share_"), t == "chat": + // share_* handled in a later pass; chat is broadcast-only } if i.broadcast != nil { i.broadcast(data) @@ -366,6 +372,18 @@ func (i *Ingestor) getCharacterStats(name string) (map[string]any, bool) { func (i *Ingestor) getEquipmentCantrip(name string) (map[string]any, bool) { return i.snapshot(i.liveEquipmentCantrip, name) } +func (i *Ingestor) getCombatStats(name string) (map[string]any, bool) { + return i.snapshot(i.liveCombatStats, name) +} +func (i *Ingestor) allCombatStats() map[string]map[string]any { + i.mu.RLock() + defer i.mu.RUnlock() + out := make(map[string]map[string]any, len(i.liveCombatStats)) + for k, v := range i.liveCombatStats { + out[k] = v + } + return out +} func (i *Ingestor) questData() (map[string]map[string]string, int) { i.mu.RLock() defer i.mu.RUnlock() diff --git a/go-services/tracker-go/main.go b/go-services/tracker-go/main.go index 824668a4..bcdfdf43 100644 --- a/go-services/tracker-go/main.go +++ b/go-services/tracker-go/main.go @@ -44,6 +44,14 @@ type Server struct { } func main() { + // `tracker-go combat-merge` reads a JSON array of cumulative session + // snapshots from stdin and prints the folded lifetime — a deterministic hook + // for cross-language parity testing against the Python combat functions. + if len(os.Args) > 1 && os.Args[1] == "combat-merge" { + runCombatMergeCLI() + return + } + logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})) slog.SetDefault(logger)