Playtime records from pt_events; sticky top bar, page scroll locked under the drawer

Records: the most bomb plants, defuses, knife, Zeus and headshot kills for
the last 7 days, 30 days or all time, and each player's counts, when
PlaytimeHistory 0.2.0's pt_events is there. The public site's top bar now
sticks the whole way down, Playtime comes after Server, and only the
player drawer scrolls while it's open.
This commit is contained in:
Astra 2026-10-04 21:37:42 +01:00
parent 6d0fc6e463
commit 7004945d49
9 changed files with 296 additions and 27 deletions

View file

@ -7,8 +7,10 @@ import (
"context"
"database/sql"
"fmt"
"slices"
"strconv"
"strings"
"sync/atomic"
"time"
"github.com/go-sql-driver/mysql"
@ -22,6 +24,20 @@ const (
TeamCT = 3
)
// Event kinds (pt_events.kind, PlaytimeHistory 0.2.0+).
const (
EventKill = 1
EventPlanted = 2
EventDefused = 3
)
// SQL for the kills that count toward records: never kills of bots.
const (
knifeKill = "kind = 1 AND (weapon LIKE 'knife%' OR weapon = 'bayonet') AND NOT victim_bot"
zeusKill = "kind = 1 AND weapon = 'taser' AND NOT victim_bot"
headshot = "kind = 1 AND headshot AND NOT victim_bot"
)
// End kinds of a session (pt_sessions.end_kind).
const (
EndOpen = 0
@ -36,7 +52,8 @@ var tables = []string{"pt_players", "pt_sessions", "pt_segments", "pt_nicknames"
type Store struct {
db *sql.DB
server int // pt_servers.id to read, or 0 for every server
server int // pt_servers.id to read, or 0 for every server
events atomic.Bool // pt_events is there (PlaytimeHistory 0.2.0+), as of the last State
}
// Open connects lazily: a database that's down only turns playtime off, never stops the panel.
@ -61,6 +78,9 @@ func New(db *sql.DB, server int) *Store { return &Store{db: db, server: server}
func (s *Store) Close() error { return s.db.Close() }
// HasEvents is whether pt_events was there at the last State.
func (s *Store) HasEvents() bool { return s.events.Load() }
// and adds the server filter to a WHERE clause on a table with server_id.
func (s *Store) and(col string, args []any) (string, []any) {
if s.server == 0 {
@ -70,14 +90,18 @@ func (s *Store) and(col string, args []any) (string, []any) {
}
// State reports whether PlaytimeHistory's tables are there, and whether it has recorded anything.
// It also notes whether pt_events is there, which older versions of the plugin don't have.
func (s *Store) State(ctx context.Context) (hasTables, hasData bool, err error) {
var n int
err = s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM information_schema.tables
WHERE table_schema = DATABASE() AND table_name IN (?, ?, ?, ?, ?)`,
tables[0], tables[1], tables[2], tables[3], tables[4]).Scan(&n)
var n, events int
err = s.db.QueryRowContext(ctx, `SELECT COUNT(DISTINCT IF(table_name = 'pt_events', NULL, table_name)),
COUNT(DISTINCT IF(table_name = 'pt_events', table_name, NULL))
FROM information_schema.tables
WHERE table_schema = DATABASE() AND table_name IN (?, ?, ?, ?, ?, 'pt_events')`,
tables[0], tables[1], tables[2], tables[3], tables[4]).Scan(&n, &events)
if err != nil || n < len(tables) {
return false, false, err
}
s.events.Store(events == 1)
where, args := s.and("server_id", nil)
err = s.db.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM pt_sessions WHERE 1=1`+where+`)`, args...).Scan(&hasData)
return true, hasData, err
@ -404,6 +428,86 @@ func (s *Store) Leaders(ctx context.Context, since *time.Time, limit int) ([]Lea
return out, krows.Err()
}
// Record is one player's count in one of the Records categories.
type Record struct {
SteamID string `json:"steamid"`
Name string `json:"name"`
Count int `json:"count"`
}
// Records is the top players for each category since a time or (nil) ever, and when events begin.
type Records struct {
Since *time.Time `json:"since,omitempty"` // the first event recorded
Plants []Record `json:"plants"`
Defuses []Record `json:"defuses"`
KnifeKills []Record `json:"knifeKills"`
ZeusKills []Record `json:"zeusKills"`
Headshots []Record `json:"headshots"`
}
func (s *Store) Records(ctx context.Context, since *time.Time, limit int) (Records, error) {
r := Records{Plants: []Record{}, Defuses: []Record{}, KnifeKills: []Record{}, ZeusKills: []Record{}, Headshots: []Record{}}
where, args := s.and("server_id", nil)
var first sql.NullTime
if err := s.db.QueryRowContext(ctx, `SELECT MIN(at) FROM pt_events WHERE 1=1`+where, args...).Scan(&first); err != nil {
return r, err
}
if first.Valid {
r.Since = &first.Time
}
if since != nil {
where, args = s.and("e.server_id", []any{*since})
where = " AND e.at >= ?" + where
} else {
where, args = s.and("e.server_id", nil)
}
rows, err := s.db.QueryContext(ctx, `SELECT e.steamid64, COALESCE(MAX(p.last_nickname), ''),
SUM(e.kind = 2), SUM(e.kind = 3), SUM(`+knifeKill+`), SUM(`+zeusKill+`), SUM(`+headshot+`)
FROM pt_events e LEFT JOIN pt_players p ON p.steamid64 = e.steamid64
WHERE 1=1`+where+` GROUP BY e.steamid64`, args...)
if err != nil {
return r, err
}
defer rows.Close()
type counts struct {
rec Record
n [5]int
}
var all []counts
for rows.Next() {
var id uint64
var c counts
var n [5]sql.NullInt64
if err := rows.Scan(&id, &c.rec.Name, &n[0], &n[1], &n[2], &n[3], &n[4]); err != nil {
return r, err
}
c.rec.SteamID = strconv.FormatUint(id, 10)
for i := range n {
c.n[i] = int(n[i].Int64)
}
all = append(all, c)
}
if err := rows.Err(); err != nil {
return r, err
}
top := func(i int) []Record {
sorted := slices.Clone(all)
slices.SortStableFunc(sorted, func(a, b counts) int { return b.n[i] - a.n[i] })
out := []Record{}
for _, c := range sorted {
if c.n[i] == 0 || len(out) == limit {
break
}
rec := c.rec
rec.Count = c.n[i]
out = append(out, rec)
}
return out
}
r.Plants, r.Defuses, r.KnifeKills, r.ZeusKills, r.Headshots = top(0), top(1), top(2), top(3), top(4)
return r, nil
}
func inList(ids []uint64) (string, []any) {
args := make([]any, len(ids))
for i, id := range ids {
@ -423,6 +527,15 @@ func ParseID(steamid string) (uint64, error) {
// ---------------------------------------------------------------- One player
// Events is one player's counts from pt_events, which only go back to PlaytimeHistory 0.2.0.
type Events struct {
Plants int `json:"plants"`
Defuses int `json:"defuses"`
KnifeKills int `json:"knifeKills"`
ZeusKills int `json:"zeusKills"`
Headshots int `json:"headshots"`
}
type Stats struct {
Kills int `json:"kills"`
Deaths int `json:"deaths"`
@ -444,7 +557,8 @@ type Player struct {
Alive float64 `json:"alive"`
Dead float64 `json:"dead"`
Stats Stats `json:"stats"`
Days []Day `json:"days"` // last 30 days
Events *Events `json:"events,omitempty"` // nil without pt_events
Days []Day `json:"days"` // last 30 days
Maps []MapTime `json:"maps"`
}
@ -470,6 +584,17 @@ func (s *Store) Player(ctx context.Context, steamid uint64, now time.Time, loc *
return nil, err
}
if s.events.Load() {
var e Events
var n [5]sql.NullInt64
if err := s.db.QueryRowContext(ctx, `SELECT SUM(kind = 2), SUM(kind = 3), SUM(`+knifeKill+`), SUM(`+zeusKill+`), SUM(`+headshot+`)
FROM pt_events WHERE steamid64 = ?`, steamid).Scan(&n[0], &n[1], &n[2], &n[3], &n[4]); err != nil {
return nil, err
}
e.Plants, e.Defuses, e.KnifeKills, e.ZeusKills, e.Headshots = int(n[0].Int64), int(n[1].Int64), int(n[2].Int64), int(n[3].Int64), int(n[4].Int64)
p.Events = &e
}
days, start := lastDays(now, loc, 30)
buckets, err := s.hours(ctx, start, steamid)
if err != nil {