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>
This commit is contained in:
parent
a5d69ba88d
commit
7350b00341
6 changed files with 347 additions and 4 deletions
|
|
@ -8,6 +8,7 @@ WORKDIR /src
|
||||||
# reads the imports from the source and writes go.mod/go.sum, then we build.
|
# reads the imports from the source and writes go.mod/go.sum, then we build.
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN go mod tidy
|
RUN go mod tidy
|
||||||
|
RUN go test ./...
|
||||||
ARG BUILD_VERSION=dev
|
ARG BUILD_VERSION=dev
|
||||||
RUN CGO_ENABLED=0 GOOS=linux go build \
|
RUN CGO_ENABLED=0 GOOS=linux go build \
|
||||||
-trimpath \
|
-trimpath \
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,14 @@ func (s *Server) handleCharacterStats(w http.ResponseWriter, r *http.Request) {
|
||||||
// so session is always null. (main.py:1819)
|
// so session is always null. (main.py:1819)
|
||||||
func (s *Server) handleCombatStatsOne(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleCombatStatsOne(w http.ResponseWriter, r *http.Request) {
|
||||||
cn := r.PathValue("character_name")
|
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)
|
ctx, cancel := reqCtx(r)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
|
|
@ -67,13 +75,25 @@ func (s *Server) handleCombatStatsAll(w http.ResponseWriter, r *http.Request) {
|
||||||
ctx, cancel := reqCtx(r)
|
ctx, cancel := reqCtx(r)
|
||||||
defer cancel()
|
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`)
|
rows, err := queryRowsAsMaps(ctx, s.pool, `SELECT character_name, stats_data FROM combat_stats`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.dbErr(w, "combat-stats/all", err)
|
s.dbErr(w, "combat-stats/all", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
results := make([]map[string]any, 0, len(rows))
|
|
||||||
for _, row := range rows {
|
for _, row := range rows {
|
||||||
|
if seen[toStr(row["character_name"])] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
results = append(results, map[string]any{
|
results = append(results, map[string]any{
|
||||||
"character_name": row["character_name"],
|
"character_name": row["character_name"],
|
||||||
"session": nil,
|
"session": nil,
|
||||||
|
|
|
||||||
242
go-services/tracker-go/combat.go
Normal file
242
go-services/tracker-go/combat.go
Normal file
|
|
@ -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()
|
||||||
|
}
|
||||||
54
go-services/tracker-go/combat_test.go
Normal file
54
go-services/tracker-go/combat_test.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -32,7 +32,9 @@ type Ingestor struct {
|
||||||
liveCombatStats map[string]map[string]any
|
liveCombatStats map[string]map[string]any
|
||||||
dungeonMapCache map[string]map[string]any
|
dungeonMapCache map[string]map[string]any
|
||||||
questStatus map[string]map[string]string
|
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 {
|
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{},
|
dungeonMapCache: map[string]map[string]any{},
|
||||||
questStatus: map[string]map[string]string{},
|
questStatus: map[string]map[string]string{},
|
||||||
lastKills: map[string]int{},
|
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)
|
i.handleNearbyObjects(data)
|
||||||
case t == "dungeon_map":
|
case t == "dungeon_map":
|
||||||
i.handleDungeonMap(data)
|
i.handleDungeonMap(data)
|
||||||
|
case t == "combat_stats":
|
||||||
|
i.handleCombatStats(ctx, data)
|
||||||
case t == "register":
|
case t == "register":
|
||||||
// no DB / no broadcast; plugin_conns belongs to the /ws/position server
|
// no DB / no broadcast; plugin_conns belongs to the /ws/position server
|
||||||
case t == "combat_stats", strings.HasPrefix(t, "share_"), t == "chat":
|
case strings.HasPrefix(t, "share_"), t == "chat":
|
||||||
// combat_stats + share_* handled in a later pass; chat is broadcast-only
|
// share_* handled in a later pass; chat is broadcast-only
|
||||||
}
|
}
|
||||||
if i.broadcast != nil {
|
if i.broadcast != nil {
|
||||||
i.broadcast(data)
|
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) {
|
func (i *Ingestor) getEquipmentCantrip(name string) (map[string]any, bool) {
|
||||||
return i.snapshot(i.liveEquipmentCantrip, name)
|
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) {
|
func (i *Ingestor) questData() (map[string]map[string]string, int) {
|
||||||
i.mu.RLock()
|
i.mu.RLock()
|
||||||
defer i.mu.RUnlock()
|
defer i.mu.RUnlock()
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,14 @@ type Server struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
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}))
|
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
||||||
slog.SetDefault(logger)
|
slog.SetDefault(logger)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue