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:
parent
6d0fc6e463
commit
7004945d49
9 changed files with 296 additions and 27 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue