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.
This commit is contained in:
Astra 2026-10-04 22:41:12 +01:00
parent c7fbff0e43
commit 2516759fa9
6 changed files with 120 additions and 81 deletions

View file

@ -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
}

View file

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

View file

@ -338,15 +338,15 @@ function ptDrawer(pt, online) {
<h3>Last 30 days</h3>
<div class="pt-spark" role="img" aria-label="Hours played each day">${p.days.map((d) => `<i class="${d.hours ? "" : "zero"}" style="height:${d.hours ? Math.max(6, (d.hours / maxDay) * 100) : 4}%" data-tip="${esc(`${ptDay(d.date)} · ${d.hours ? ptH(d.hours) : "didn't play"}`)}"></i>`).join("")}</div>
</section>
${st.kills + st.deaths > 0 ? `<section>
${st && st.kills + st.deaths > 0 ? `<section>
<h3>Stats</h3>
<div class="pt-stats">
<div><b>${ptKD(st.kills, st.deaths)}</b><small>K/D</small></div>
<div><b>${ptPct(st.headshots, st.kills)}%</b><small>Headshot kills</small></div>
<div><b>${st.rounds ? Math.round(st.damage / st.rounds) : "–"}</b><small>Damage per round</small></div>
<div><b>${st.mvps}</b><small>MVPs</small></div>
<div><b>${st.kills.toLocaleString()}</b><small>Kills</small></div>
<div><b>${st.deaths.toLocaleString()}</b><small>Deaths</small></div>
</div>
<p class="muted" style="margin:10px 0 0;font-size:14px">${st.kills.toLocaleString()} kills, ${st.deaths.toLocaleString()} deaths and ${st.assists.toLocaleString()} assists over ${st.rounds.toLocaleString()} rounds.</p>
<p class="muted" style="margin:10px 0 0;font-size:14px">Against other players only: bots don't count.</p>
</section>` : ""}
${p.events && Object.values(p.events).some((n) => n > 0) ? `<section>
<h3>Records</h3>
@ -356,7 +356,6 @@ function ptDrawer(pt, online) {
<div><b>${p.events.knifeKills.toLocaleString()}</b><small>Knife kills</small></div>
<div><b>${p.events.zeusKills.toLocaleString()}</b><small>Zeus kills</small></div>
</div>
<p class="muted" style="margin:10px 0 0;font-size:14px">${p.events.headshots.toLocaleString()} headshot kills. Counted as they happen, not in warmup or on bots.</p>
</section>` : ""}
${p.maps.length ? `<section>
<h3>Favourite maps</h3>
@ -381,17 +380,14 @@ function ptDrawer(pt, online) {
<h3>Recent sessions</h3>
<ul class="pt-sessions">${s.recent.map((x) => `<li>
<div class="top"><b>${fmtDate(x.started)}</b><span>${ptDur(x.seconds)}</span></div>
<div class="muted">${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` : ""}</div>
<div class="muted">${esc(x.map)}${x.endMap && x.endMap !== x.map ? ` → ${esc(x.endMap)}` : ""}${x.kills != null ? ` · ${x.kills}–${x.deaths}` : ""}</div>
<div>${reason(x)}</div>
</li>`).join("")}</ul>
</section>` : ""}
${s.country || s.ping != null ? `<section>
<h3>Connection</h3>
<dl class="kv">
${s.country ? `<dt>Location</dt><dd>${s.city ? `${esc(s.city)}, ` : ""}${esc(s.countryName || s.country)}</dd>` : ""}
${s.ping != null ? `<dt>Average ping</dt><dd>${s.ping} ms <span class="muted">last 30 days</span></dd>` : ""}
</dl>
</section>${s.country ? PT_DBIP : ""}` : ""}`;
${s.country ? `<section>
<h3>Location</h3>
<p style="margin:0">${s.city ? `${esc(s.city)}, ` : ""}${esc(s.countryName || s.country)}</p>
</section>${PT_DBIP}` : ""}`;
}
/* ---------- Events ---------- */

View file

@ -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;

View file

@ -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