From 2516759fa9bbff040bff0434bc33ee84a901f153 Mon Sep 17 00:00:00 2001 From: Astra Date: Sun, 4 Oct 2026 22:41:12 +0100 Subject: [PATCH] Playtime stats between players only; no ping; page-wide grid K/D, headshot %, kills and deaths (drawer, Top players, staff session lists) now come from pt_events, so kills of bots and deaths to bots don't count, as in Records; damage per round and MVPs are gone, since only the game's counters have them and those include bots. Average ping and the record count footnote are no longer shown. The server header's grid now runs behind the whole page. --- README.md | 8 +- internal/playtime/playtime.go | 125 ++++++++++++++++---------- internal/playtime/playtime_db_test.go | 36 ++++---- internal/web/assets/playtime.js | 22 ++--- internal/web/assets/styles.css | 8 +- internal/web/playtime.go | 2 +- 6 files changed, 120 insertions(+), 81 deletions(-) diff --git a/README.md b/README.md index 677d352..48bf26a 100644 --- a/README.md +++ b/README.md @@ -169,12 +169,14 @@ SAW_PT_DSN=pt_web:@tcp(:3306)/playtime_history PlaytimeHistory 0.2.0+ (its `pt_events` table), also Records: the most bomb plants, defuses, knife kills, Zeus kills and headshot kills for the same periods, counted as they happen, never in warmup, and not counting kills of bots. Player pages get time played with the T/CT/spectating - split, the last 30 days, K/D, headshots, damage per round, MVPs, their record counts and - favourite maps. + split, the last 30 days, K/D, headshot %, kills and deaths, their record counts and + favourite maps. Kills, deaths and headshots (here, on Top players and in staff session lists) + are between players only, from `pt_events`: kills of bots, deaths to bots and suicides don't + count, so they start with PlaytimeHistory 0.2.0. - **Staff** with `@css/generic` (PlaytimeHistory's own default for looking up others) get, on the staff panel's Playtime page and on the public site while signed in: who's on now with how long, team, ping and location; each staff member's time in the last 7 or 30 days; disconnect reasons; - countries; and in player drawers, the names they've used, recent sessions, location and ping. + countries; and in player drawers, the names they've used, recent sessions and location. Root also sees whether the plugin is loaded and what `css_pt_status` says. Who's on comes from the live status, so admins hidden with `css_hide` stay hidden. - **Days and hours of the day** are in the viewer's own time zone (the browser sends it). An hour diff --git a/internal/playtime/playtime.go b/internal/playtime/playtime.go index a69a6cd..ca6c6c5 100644 --- a/internal/playtime/playtime.go +++ b/internal/playtime/playtime.go @@ -431,31 +431,63 @@ func (s *Store) Leaders(ctx context.Context, since *time.Time, limit int) ([]Lea return out, err } - in, args := inList(ids) - q := `SELECT steamid64, COALESCE(SUM(kills), 0), COALESCE(SUM(deaths), 0) FROM pt_sessions WHERE steamid64 IN (` + in + `)` - if since != nil { - q += " AND started_at >= ?" - args = append(args, *since) + if !s.events.Load() { + return out, nil } - where, args := s.and("server_id", args) - krows, err := s.db.QueryContext(ctx, q+where+" GROUP BY steamid64", args...) + kd, err := s.killsDeaths(ctx, ids, since) if err != nil { return nil, err } - defer krows.Close() - kd := map[string][2]int{} - for krows.Next() { + for i, id := range ids { + out[i].Kills, out[i].Deaths = kd[id].Kills, kd[id].Deaths + } + return out, nil +} + +// killsDeaths counts kills and deaths between players from pt_events, since a time or (nil) +// ever. Kills of bots and deaths to bots, the world or themselves don't count: only a player +// killing another player is logged with a victim. +func (s *Store) killsDeaths(ctx context.Context, ids []uint64, since *time.Time) (map[uint64]Stats, error) { + in, ids1 := inList(ids) + _, ids2 := inList(ids) + when := "" + var args []any + part := func(idArgs []any) []any { + a := append([]any{}, idArgs...) + if since != nil { + a = append(a, *since) + } + if s.server != 0 { + a = append(a, s.server) + } + return a + } + if since != nil { + when = " AND at >= ?" + } + srv, _ := s.and("server_id", nil) + args = append(part(ids1), part(ids2)...) + rows, err := s.db.QueryContext(ctx, `SELECT id, SUM(k), SUM(d), SUM(h) FROM ( + SELECT steamid64 AS id, 1 AS k, 0 AS d, IF(headshot, 1, 0) AS h FROM pt_events + WHERE kind = 1 AND NOT victim_bot AND steamid64 IN (`+in+`)`+when+srv+` + UNION ALL + SELECT victim_steamid64, 0, 1, 0 FROM pt_events + WHERE kind = 1 AND victim_steamid64 IN (`+in+`)`+when+srv+` + ) x GROUP BY id`, args...) + if err != nil { + return nil, err + } + defer rows.Close() + out := map[uint64]Stats{} + for rows.Next() { var id uint64 - var k, d int - if err := krows.Scan(&id, &k, &d); err != nil { + var st Stats + if err := rows.Scan(&id, &st.Kills, &st.Deaths, &st.Headshots); err != nil { return nil, err } - kd[strconv.FormatUint(id, 10)] = [2]int{k, d} + out[id] = st } - for i := range out { - out[i].Kills, out[i].Deaths = kd[out[i].SteamID][0], kd[out[i].SteamID][1] - } - return out, krows.Err() + return out, rows.Err() } // Record is one player's count in one of the Records categories. @@ -566,14 +598,11 @@ type Events struct { Headshots int `json:"headshots"` } +// Stats is kills, deaths and headshot kills between players (pt_events, so 0.2.0 onward). type Stats struct { Kills int `json:"kills"` Deaths int `json:"deaths"` - Assists int `json:"assists"` Headshots int `json:"headshots"` - Damage int `json:"damage"` - MVPs int `json:"mvps"` - Rounds int `json:"rounds"` } // Player is what anyone can see about one player's playtime. Hours are on T or CT unless named. @@ -586,7 +615,7 @@ type Player struct { Spec float64 `json:"spec"` // spectating or unassigned Alive float64 `json:"alive"` Dead float64 `json:"dead"` - Stats Stats `json:"stats"` + Stats *Stats `json:"stats,omitempty"` // nil without pt_events Events *Events `json:"events,omitempty"` // nil without pt_events Days []Day `json:"days"` // last 30 days Maps []MapTime `json:"maps"` @@ -607,11 +636,13 @@ func (s *Store) Player(ctx context.Context, steamid uint64, now time.Time, loc * h := func(secs int64) float64 { return round1(float64(secs) / 3600) } p.T, p.CT, p.Spec, p.Alive, p.Dead = h(t), h(ct), h(spec), h(alive), h(dead) - if err := s.db.QueryRowContext(ctx, `SELECT COALESCE(SUM(kills), 0), COALESCE(SUM(deaths), 0), COALESCE(SUM(assists), 0), - COALESCE(SUM(headshots), 0), COALESCE(SUM(damage), 0), COALESCE(SUM(mvp), 0), COALESCE(SUM(rounds_played), 0) - FROM pt_sessions WHERE steamid64 = ?`, steamid). - Scan(&p.Stats.Kills, &p.Stats.Deaths, &p.Stats.Assists, &p.Stats.Headshots, &p.Stats.Damage, &p.Stats.MVPs, &p.Stats.Rounds); err != nil { - return nil, err + if s.events.Load() { + kd, err := s.killsDeaths(ctx, []uint64{steamid}, nil) + if err != nil { + return nil, err + } + st := kd[steamid] + p.Stats = &st } if s.events.Load() { @@ -655,22 +686,20 @@ type Session struct { Reason string `json:"reason,omitempty"` Kills *int `json:"kills,omitempty"` Deaths *int `json:"deaths,omitempty"` - Ping *int `json:"ping,omitempty"` Country string `json:"country,omitempty"` City string `json:"city,omitempty"` } -// PlayerStaff is what only staff see: names, sessions, location and ping. +// PlayerStaff is what only staff see: names, sessions and location. type PlayerStaff struct { Names []Nickname `json:"names"` Recent []Session `json:"recent"` Country string `json:"country,omitempty"` CountryName string `json:"countryName,omitempty"` City string `json:"city,omitempty"` - Ping *int `json:"ping,omitempty"` // average over the last 30 days } -func (s *Store) PlayerStaff(ctx context.Context, steamid uint64, now time.Time) (PlayerStaff, error) { +func (s *Store) PlayerStaff(ctx context.Context, steamid uint64) (PlayerStaff, error) { out := PlayerStaff{Names: []Nickname{}, Recent: []Session{}} rows, err := s.db.QueryContext(ctx, `SELECT nickname, first_seen, last_seen, times_seen FROM pt_nicknames WHERE steamid64 = ? ORDER BY last_seen DESC LIMIT 20`, steamid) @@ -690,19 +719,28 @@ func (s *Store) PlayerStaff(ctx context.Context, steamid uint64, now time.Time) return out, err } - where, args := s.and("server_id", []any{steamid}) - rows, err = s.db.QueryContext(ctx, `SELECT started_at, ended_at, COALESCE(duration_seconds, TIMESTAMPDIFF(SECOND, started_at, heartbeat_at)), - end_kind, connect_map, COALESCE(disconnect_map, ''), COALESCE(disconnect_reason_name, ''), kills, deaths, ping_avg, - COALESCE(country_iso, ''), COALESCE(city, '') - FROM pt_sessions WHERE steamid64 = ?`+where+` ORDER BY started_at DESC LIMIT 10`, args...) + // Kills and deaths between players from pt_events, for sessions since it began; NULL before. + kd := "NULL, NULL" + if s.events.Load() { + kd = `IF(s.started_at >= (SELECT MIN(at) FROM pt_events), + (SELECT COUNT(*) FROM pt_events e WHERE e.session_key = s.session_key AND e.kind = 1 AND NOT e.victim_bot), NULL), + IF(s.started_at >= (SELECT MIN(at) FROM pt_events), + (SELECT COUNT(*) FROM pt_events e WHERE e.kind = 1 AND e.victim_steamid64 = s.steamid64 + AND e.at >= s.started_at AND e.at <= COALESCE(s.ended_at, s.heartbeat_at, UTC_TIMESTAMP())), NULL)` + } + where, args := s.and("s.server_id", []any{steamid}) + rows, err = s.db.QueryContext(ctx, `SELECT s.started_at, s.ended_at, COALESCE(s.duration_seconds, TIMESTAMPDIFF(SECOND, s.started_at, s.heartbeat_at)), + s.end_kind, s.connect_map, COALESCE(s.disconnect_map, ''), COALESCE(s.disconnect_reason_name, ''), `+kd+`, + COALESCE(s.country_iso, ''), COALESCE(s.city, '') + FROM pt_sessions s WHERE s.steamid64 = ?`+where+` ORDER BY s.started_at DESC LIMIT 10`, args...) if err != nil { return out, err } for rows.Next() { var x Session var ended sql.NullTime - var kills, deaths, ping sql.NullInt64 - if err := rows.Scan(&x.Started, &ended, &x.Seconds, &x.EndKind, &x.Map, &x.EndMap, &x.Reason, &kills, &deaths, &ping, + var kills, deaths sql.NullInt64 + if err := rows.Scan(&x.Started, &ended, &x.Seconds, &x.EndKind, &x.Map, &x.EndMap, &x.Reason, &kills, &deaths, &x.Country, &x.City); err != nil { rows.Close() return out, err @@ -710,7 +748,7 @@ func (s *Store) PlayerStaff(ctx context.Context, steamid uint64, now time.Time) if ended.Valid { x.Ended = &ended.Time } - x.Kills, x.Deaths, x.Ping = intPtr(kills), intPtr(deaths), intPtr(ping) + x.Kills, x.Deaths = intPtr(kills), intPtr(deaths) out.Recent = append(out.Recent, x) } rows.Close() @@ -724,15 +762,6 @@ func (s *Store) PlayerStaff(ctx context.Context, steamid uint64, now time.Time) if err != nil && err != sql.ErrNoRows { return out, err } - var ping sql.NullFloat64 - if err := s.db.QueryRowContext(ctx, `SELECT AVG(ping_avg) FROM pt_sessions WHERE steamid64 = ? AND started_at >= ?`, - steamid, now.Add(-30*24*time.Hour)).Scan(&ping); err != nil { - return out, err - } - if ping.Valid { - n := int(ping.Float64 + 0.5) - out.Ping = &n - } return out, nil } diff --git a/internal/playtime/playtime_db_test.go b/internal/playtime/playtime_db_test.go index 371c06c..d93bb94 100644 --- a/internal/playtime/playtime_db_test.go +++ b/internal/playtime/playtime_db_test.go @@ -116,18 +116,20 @@ func TestPlaytimeDB(t *testing.T) { // Events: Alpha plants twice (once 10 days ago), defuses once and knifes a player and a bot; // Bravo gets a Zeus kill and a headshot. - event := func(id uint64, kind int, at time.Time, weapon any, hs any, bot any) { + // Kills say who died (nil for a bot) and the killer's session. + event := func(id uint64, sess string, kind int, at time.Time, weapon any, hs any, victim any, bot any) { t.Helper() - exec(`INSERT INTO pt_events (event_key, server_id, steamid64, kind, at, map, team, weapon, headshot, victim_bot) - VALUES (REPLACE(UUID(), '-', ''), 1, ?, ?, ?, 'de_mirage', 2, ?, ?, ?)`, id, kind, at, weapon, hs, bot) + exec(`INSERT INTO pt_events (event_key, server_id, session_key, steamid64, kind, at, map, team, weapon, headshot, + victim_steamid64, victim_bot) + VALUES (REPLACE(UUID(), '-', ''), 1, NULLIF(?, ''), ?, ?, ?, 'de_mirage', 2, ?, ?, ?, ?)`, sess, id, kind, at, weapon, hs, victim, bot) } - event(a, EventPlanted, ago(2*time.Hour), nil, nil, nil) - event(a, EventPlanted, ago(10*24*time.Hour), nil, nil, nil) - event(a, EventDefused, ago(2*time.Hour), nil, nil, nil) - event(a, EventKill, ago(2*time.Hour), "knife_karambit", 0, 0) - event(a, EventKill, ago(2*time.Hour), "knife", 1, 1) // a bot: doesn't count, not even as a headshot - event(b, EventKill, ago(20*time.Minute), "taser", 0, 0) - event(b, EventKill, ago(20*time.Minute), "ak47", 1, 0) + event(a, "", EventPlanted, ago(10*24*time.Hour), nil, nil, nil, nil) + event(a, "a1", EventPlanted, ago(2*time.Hour), nil, nil, nil, nil) + event(a, "a1", EventDefused, ago(2*time.Hour), nil, nil, nil, nil) + event(a, "a1", EventKill, ago(2*time.Hour), "knife_karambit", 0, b, 0) + event(a, "a1", EventKill, ago(2*time.Hour), "knife", 1, nil, 1) // a bot: doesn't count, not even as a headshot + event(b, "b2", EventKill, ago(20*time.Minute), "taser", 0, a, 0) + event(b, "b2", EventKill, ago(20*time.Minute), "ak47", 1, a, 0) if ok, data, err := st.State(ctx); err != nil || !ok || !data || !st.HasEvents() { t.Fatalf("State with data: %v, %v, %v (events %v)", ok, data, err, st.HasEvents()) @@ -194,13 +196,13 @@ func TestPlaytimeDB(t *testing.T) { if err != nil { t.Fatal(err) } - if len(leaders) != 1 || leaders[0].Name != "Alpha" || leaders[0].T != 1 || leaders[0].CT != 0.5 || leaders[0].Kills != 10 || leaders[0].Deaths != 5 { + if len(leaders) != 1 || leaders[0].Name != "Alpha" || leaders[0].T != 1 || leaders[0].CT != 0.5 || leaders[0].Kills != 1 || leaders[0].Deaths != 2 { t.Fatalf("leaders this week %+v", leaders) } if leaders, err = st.Leaders(ctx, nil, 10); err != nil { t.Fatal(err) } - if len(leaders) != 2 || leaders[0].Name != "Bravo" || leaders[0].Kills != 3 { + if len(leaders) != 2 || leaders[0].Name != "Bravo" || leaders[0].Kills != 2 || leaders[0].Deaths != 1 { t.Fatalf("leaders all time %+v", leaders) } @@ -212,19 +214,23 @@ func TestPlaytimeDB(t *testing.T) { for _, d := range p.Days { days += d.Hours } - if p.T != 1 || p.CT != 0.5 || p.Spec != 0.5 || p.Stats.Kills != 10 || p.Stats.Rounds != 10 || days != 1.5 || len(p.Maps) != 1 { + if p.T != 1 || p.CT != 0.5 || p.Spec != 0.5 || p.Stats == nil || *p.Stats != (Stats{Kills: 1, Deaths: 2}) || days != 1.5 || len(p.Maps) != 1 { t.Fatalf("player %+v, %.1f h in days", p, days) } if p, err := st.Player(ctx, 76561198000009999, now, london); err != nil || p != nil { t.Fatalf("unknown player: %v, %v", p, err) } - ps, err := st.PlayerStaff(ctx, a, now) + ps, err := st.PlayerStaff(ctx, a) if err != nil { t.Fatal(err) } + // Alpha's knife kill was in a1; Bravo killed Alpha twice, but after a1 ended. + if r := ps.Recent; len(r) != 1 || r[0].Kills == nil || *r[0].Kills != 1 || r[0].Deaths == nil || *r[0].Deaths != 0 { + t.Fatalf("recent session kills and deaths %+v", ps.Recent) + } if len(ps.Names) != 2 || ps.Names[0].Name != "Alpha" || len(ps.Recent) != 1 || ps.Recent[0].Seconds != 7200 || - ps.Recent[0].Reason != "NETWORK_DISCONNECT_DISCONNECT_BY_USER" || ps.Country != "GB" || ps.City != "London" || ps.Ping == nil || *ps.Ping != 20 { + ps.Recent[0].Reason != "NETWORK_DISCONNECT_DISCONNECT_BY_USER" || ps.Country != "GB" || ps.City != "London" { t.Fatalf("staff %+v", ps) } diff --git a/internal/web/assets/playtime.js b/internal/web/assets/playtime.js index cd514e5..3fd13cb 100644 --- a/internal/web/assets/playtime.js +++ b/internal/web/assets/playtime.js @@ -338,15 +338,15 @@ function ptDrawer(pt, online) {

Last 30 days

- ${st.kills + st.deaths > 0 ? `
+ ${st && st.kills + st.deaths > 0 ? `

Stats

${ptKD(st.kills, st.deaths)}K/D
${ptPct(st.headshots, st.kills)}%Headshot kills
-
${st.rounds ? Math.round(st.damage / st.rounds) : "–"}Damage per round
-
${st.mvps}MVPs
+
${st.kills.toLocaleString()}Kills
+
${st.deaths.toLocaleString()}Deaths
-

${st.kills.toLocaleString()} kills, ${st.deaths.toLocaleString()} deaths and ${st.assists.toLocaleString()} assists over ${st.rounds.toLocaleString()} rounds.

+

Against other players only: bots don't count.

` : ""} ${p.events && Object.values(p.events).some((n) => n > 0) ? `

Records

@@ -356,7 +356,6 @@ function ptDrawer(pt, online) {
${p.events.knifeKills.toLocaleString()}Knife kills
${p.events.zeusKills.toLocaleString()}Zeus kills
-

${p.events.headshots.toLocaleString()} headshot kills. Counted as they happen, not in warmup or on bots.

` : ""} ${p.maps.length ? `

Favourite maps

@@ -381,17 +380,14 @@ function ptDrawer(pt, online) {

Recent sessions

    ${s.recent.map((x) => `
  • ${fmtDate(x.started)}${ptDur(x.seconds)}
    -
    ${esc(x.map)}${x.endMap && x.endMap !== x.map ? ` → ${esc(x.endMap)}` : ""}${x.kills != null ? ` · ${x.kills}–${x.deaths}` : ""}${x.ping != null ? ` · ${x.ping} ms` : ""}
    +
    ${esc(x.map)}${x.endMap && x.endMap !== x.map ? ` → ${esc(x.endMap)}` : ""}${x.kills != null ? ` · ${x.kills}–${x.deaths}` : ""}
    ${reason(x)}
  • `).join("")}
` : ""} - ${s.country || s.ping != null ? `
-

Connection

-
- ${s.country ? `
Location
${s.city ? `${esc(s.city)}, ` : ""}${esc(s.countryName || s.country)}
` : ""} - ${s.ping != null ? `
Average ping
${s.ping} ms last 30 days
` : ""} -
-
${s.country ? PT_DBIP : ""}` : ""}`; + ${s.country ? `
+

Location

+

${s.city ? `${esc(s.city)}, ` : ""}${esc(s.countryName || s.country)}

+
${PT_DBIP}` : ""}`; } /* ---------- Events ---------- */ diff --git a/internal/web/assets/styles.css b/internal/web/assets/styles.css index 4debd0a..a848ae0 100644 --- a/internal/web/assets/styles.css +++ b/internal/web/assets/styles.css @@ -36,7 +36,13 @@ html.drawer-open { overflow: hidden; } html.drawer-open body { overflow: hidden; } body { - background: var(--bg); + /* The server header's 24px grid, across the whole page (fainter: the page is darker than a + panel), fixed so content scrolls over it. Panels are solid, so it shows between them. */ + background: + repeating-linear-gradient(0deg, transparent 0 23px, rgba(255, 255, 255, 0.028) 23px 24px), + repeating-linear-gradient(90deg, transparent 0 23px, rgba(255, 255, 255, 0.028) 23px 24px), + var(--bg); + background-attachment: fixed; color: var(--text); font: 400 15px/1.5 var(--body); font-variant-numeric: tabular-nums; diff --git a/internal/web/playtime.go b/internal/web/playtime.go index c7091d2..bec582e 100644 --- a/internal/web/playtime.go +++ b/internal/web/playtime.go @@ -359,7 +359,7 @@ func (s *Server) playerPlaytime(r *http.Request, steamid string, staff bool) map } out := map[string]any{"player": p} if staff { - extra, err := s.pt.PlayerStaff(ctx, n, now) + extra, err := s.pt.PlayerStaff(ctx, n) if err != nil { slog.Warn("staff playtime for a player", "steamid", steamid, "err", err) return out