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.
955 lines
30 KiB
Go
955 lines
30 KiB
Go
// Package playtime reads PlaytimeHistory's database (plugins/PlaytimeHistory in the cs2 repo): who
|
|
// played when, on which team and map. It's a separate database from SimpleAdmin's, read-only from
|
|
// here. All its times are UTC.
|
|
package playtime
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/go-sql-driver/mysql"
|
|
)
|
|
|
|
// Teams as PlaytimeHistory records them.
|
|
const (
|
|
TeamUnassigned = 0
|
|
TeamSpectator = 1
|
|
TeamT = 2
|
|
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
|
|
EndDisconnect = 1
|
|
EndMapChange = 2
|
|
EndUnload = 3
|
|
EndStale = 4 // closed at its last heartbeat after the server died
|
|
)
|
|
|
|
// tables are the ones the panel reads. Without all of them, playtime is off.
|
|
var tables = []string{"pt_players", "pt_sessions", "pt_segments", "pt_nicknames", "pt_online_snapshots"}
|
|
|
|
type Store struct {
|
|
db *sql.DB
|
|
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.
|
|
func Open(dsn string, server int) (*Store, error) {
|
|
cfg, err := mysql.ParseDSN(dsn)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
cfg.ParseTime = true
|
|
cfg.Loc = time.UTC
|
|
db, err := sql.Open("mysql", cfg.FormatDSN())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
db.SetMaxOpenConns(5)
|
|
db.SetConnMaxLifetime(5 * time.Minute)
|
|
return &Store{db: db, server: server}, nil
|
|
}
|
|
|
|
// New wraps an open database, for tests.
|
|
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 {
|
|
return "", args
|
|
}
|
|
return " AND " + col + " = ?", append(args, s.server)
|
|
}
|
|
|
|
// 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, 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
|
|
}
|
|
|
|
// ---------------------------------------------------------------- Hourly buckets
|
|
|
|
// Bucket is the seconds played on T or CT in one UTC hour.
|
|
type Bucket struct {
|
|
Start time.Time
|
|
Seconds int64
|
|
}
|
|
|
|
// hours splits closed T/CT segments since a time into UTC hours, for one player or ("") everyone.
|
|
// Segments end at the latest on a map change, so none is longer than a day.
|
|
func (s *Store) hours(ctx context.Context, since time.Time, steamid uint64) ([]Bucket, error) {
|
|
args := []any{since, since, since.Add(-24 * time.Hour)}
|
|
filter := ""
|
|
if steamid != 0 {
|
|
filter = " AND steamid64 = ?"
|
|
args = append(args, steamid)
|
|
}
|
|
where, args := s.and("server_id", args)
|
|
rows, err := s.db.QueryContext(ctx, `WITH RECURSIVE seg AS (
|
|
SELECT GREATEST(started_at, ?) AS t0, ended_at AS t1 FROM pt_segments
|
|
WHERE ended_at IS NOT NULL AND ended_at > started_at AND team IN (2, 3)
|
|
AND ended_at > ? AND started_at >= ?`+filter+where+`
|
|
), h AS (
|
|
SELECT t1, CAST(DATE_FORMAT(t0, '%Y-%m-%d %H:00:00') AS DATETIME) AS b, t0 AS p FROM seg
|
|
UNION ALL
|
|
SELECT t1, b + INTERVAL 1 HOUR, b + INTERVAL 1 HOUR FROM h WHERE b + INTERVAL 1 HOUR < t1
|
|
)
|
|
SELECT b, SUM(TIMESTAMPDIFF(SECOND, p, LEAST(t1, b + INTERVAL 1 HOUR))) FROM h GROUP BY b ORDER BY b`, args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []Bucket
|
|
for rows.Next() {
|
|
var b Bucket
|
|
if err := rows.Scan(&b.Start, &b.Seconds); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, b)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// dayStart is local midnight on t's day in loc.
|
|
func dayStart(t time.Time, loc *time.Location) time.Time {
|
|
y, m, d := t.In(loc).Date()
|
|
return time.Date(y, m, d, 0, 0, 0, 0, loc)
|
|
}
|
|
|
|
// Day is one local day: hours played on T/CT, or different players who joined.
|
|
type Day struct {
|
|
Date string `json:"date"` // 2006-01-02, in the viewer's time zone
|
|
Hours float64 `json:"hours,omitempty"`
|
|
Players int `json:"players,omitempty"`
|
|
}
|
|
|
|
// lastDays is the n local days ending today, oldest first, and the start of the first.
|
|
func lastDays(now time.Time, loc *time.Location, n int) ([]Day, time.Time) {
|
|
today := dayStart(now, loc)
|
|
days := make([]Day, n)
|
|
for i := range days {
|
|
days[i].Date = today.AddDate(0, 0, i-n+1).Format(time.DateOnly)
|
|
}
|
|
return days, today.AddDate(0, 0, 1-n)
|
|
}
|
|
|
|
// dayIndex finds t's local day in days, or -1.
|
|
func dayIndex(days []Day, t time.Time, loc *time.Location) int {
|
|
key := t.In(loc).Format(time.DateOnly)
|
|
for i := len(days) - 1; i >= 0; i-- {
|
|
if days[i].Date == key {
|
|
return i
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
// hoursPerDay adds the buckets up into days. An hour bucket goes to the local day it starts on,
|
|
// which is exact for whole-hour time zones.
|
|
func hoursPerDay(days []Day, buckets []Bucket, loc *time.Location) {
|
|
for _, b := range buckets {
|
|
if i := dayIndex(days, b.Start, loc); i >= 0 {
|
|
days[i].Hours += float64(b.Seconds) / 3600
|
|
}
|
|
}
|
|
for i := range days {
|
|
days[i].Hours = round1(days[i].Hours)
|
|
}
|
|
}
|
|
|
|
func round1(f float64) float64 { return float64(int64(f*10+0.5)) / 10 }
|
|
func round2(f float64) float64 { return float64(int64(f*100+0.5)) / 100 }
|
|
|
|
// ---------------------------------------------------------------- Overview (public)
|
|
|
|
type Summary struct {
|
|
Players int `json:"players"`
|
|
PlayersPrev int `json:"playersPrev"`
|
|
Hours float64 `json:"hours"`
|
|
HoursPrev float64 `json:"hoursPrev"`
|
|
Peak int `json:"peak"`
|
|
PeakAt *time.Time `json:"peakAt,omitempty"`
|
|
NewPlayers int `json:"newPlayers"`
|
|
}
|
|
|
|
type Leader struct {
|
|
SteamID string `json:"steamid"`
|
|
Name string `json:"name"`
|
|
T float64 `json:"t"` // hours
|
|
CT float64 `json:"ct"`
|
|
Kills int `json:"kills"`
|
|
Deaths int `json:"deaths"`
|
|
}
|
|
|
|
type MapTime struct {
|
|
Map string `json:"map"`
|
|
Hours float64 `json:"hours"`
|
|
Players int `json:"players,omitempty"`
|
|
}
|
|
|
|
// Overview is the public Playtime page. Week figures are the last 7 days; charts the last 30
|
|
// (the heatmap the last 28, four of each weekday), in the viewer's time zone.
|
|
type Overview struct {
|
|
Summary Summary `json:"summary"`
|
|
ByHour [24]float64 `json:"byHour"` // average players on T/CT in each local hour of the day
|
|
Heat [7][24]float64 `json:"heat"` // the same per weekday, Monday first
|
|
// The days ByHour and Heat average over: the last 30 (28 for Heat), or fewer while the
|
|
// recording is younger than that, so a new server doesn't look emptier than it is.
|
|
ByHourDays int `json:"byHourDays"`
|
|
HeatDays int `json:"heatDays"`
|
|
Days []Day `json:"days"`
|
|
Maps []MapTime `json:"maps"`
|
|
}
|
|
|
|
func (s *Store) Overview(ctx context.Context, now time.Time, loc *time.Location) (Overview, error) {
|
|
var o Overview
|
|
days, start := lastDays(now, loc, 30)
|
|
buckets, err := s.hours(ctx, start, 0)
|
|
if err != nil {
|
|
return o, err
|
|
}
|
|
week, fortnight := now.Add(-7*24*time.Hour), now.Add(-14*24*time.Hour)
|
|
heatFrom := dayStart(now, loc).AddDate(0, 0, -27)
|
|
|
|
// Average over the days there's been recording for, not days from before it began.
|
|
where, args := s.and("server_id", nil)
|
|
var first sql.NullTime
|
|
if err := s.db.QueryRowContext(ctx, `SELECT MIN(started_at) FROM pt_sessions WHERE 1=1`+where, args...).Scan(&first); err != nil {
|
|
return o, err
|
|
}
|
|
byHourFrom, heatStart := start, heatFrom
|
|
if first.Valid {
|
|
if f := dayStart(first.Time, loc); f.After(byHourFrom) {
|
|
byHourFrom = f
|
|
}
|
|
if f := dayStart(first.Time, loc); f.After(heatStart) {
|
|
heatStart = f
|
|
}
|
|
}
|
|
today := dayStart(now, loc)
|
|
var weekdays [7]int // how many of each weekday the heatmap covers
|
|
for d := heatStart; !d.After(today); d = d.AddDate(0, 0, 1) {
|
|
weekdays[(int(d.Weekday())+6)%7]++
|
|
o.HeatDays++
|
|
}
|
|
for d := byHourFrom; !d.After(today); d = d.AddDate(0, 0, 1) {
|
|
o.ByHourDays++
|
|
}
|
|
|
|
for _, b := range buckets {
|
|
h := float64(b.Seconds) / 3600
|
|
local := b.Start.In(loc)
|
|
o.ByHour[local.Hour()] += h / float64(max(o.ByHourDays, 1))
|
|
if wd := (int(local.Weekday()) + 6) % 7; !local.Before(heatFrom) && weekdays[wd] > 0 {
|
|
o.Heat[wd][local.Hour()] += h / float64(weekdays[wd])
|
|
}
|
|
switch {
|
|
case !b.Start.Before(week):
|
|
o.Summary.Hours += h
|
|
case !b.Start.Before(fortnight):
|
|
o.Summary.HoursPrev += h
|
|
}
|
|
}
|
|
// Two decimals: a quiet hour averages well under one player.
|
|
for i := range o.ByHour {
|
|
o.ByHour[i] = round2(o.ByHour[i])
|
|
}
|
|
for d := range o.Heat {
|
|
for h := range o.Heat[d] {
|
|
o.Heat[d][h] = round2(o.Heat[d][h])
|
|
}
|
|
}
|
|
o.Summary.Hours, o.Summary.HoursPrev = round1(o.Summary.Hours), round1(o.Summary.HoursPrev)
|
|
|
|
where, args = s.and("server_id", []any{week, fortnight, week, fortnight})
|
|
if err := s.db.QueryRowContext(ctx, `SELECT
|
|
COUNT(DISTINCT IF(started_at >= ?, steamid64, NULL)),
|
|
COUNT(DISTINCT IF(started_at >= ? AND started_at < ?, steamid64, NULL))
|
|
FROM pt_sessions WHERE started_at >= ?`+where, args...).Scan(&o.Summary.Players, &o.Summary.PlayersPrev); err != nil {
|
|
return o, err
|
|
}
|
|
where, args = s.and("server_id", []any{week})
|
|
var peakAt sql.NullTime
|
|
err = s.db.QueryRowContext(ctx, `SELECT players, taken_at FROM pt_online_snapshots
|
|
WHERE taken_at >= ?`+where+` ORDER BY players DESC, taken_at DESC LIMIT 1`, args...).Scan(&o.Summary.Peak, &peakAt)
|
|
if err != nil && err != sql.ErrNoRows {
|
|
return o, err
|
|
}
|
|
if peakAt.Valid {
|
|
o.Summary.PeakAt = &peakAt.Time
|
|
}
|
|
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM pt_players WHERE first_seen >= ?`, week).Scan(&o.Summary.NewPlayers); err != nil {
|
|
return o, err
|
|
}
|
|
|
|
// Different players per local day: by the hour each session started in.
|
|
where, args = s.and("server_id", []any{start})
|
|
rows, err := s.db.QueryContext(ctx, `SELECT DISTINCT steamid64, CAST(DATE_FORMAT(started_at, '%Y-%m-%d %H:00:00') AS DATETIME)
|
|
FROM pt_sessions WHERE started_at >= ?`+where, args...)
|
|
if err != nil {
|
|
return o, err
|
|
}
|
|
seen := make([]map[uint64]bool, len(days))
|
|
for rows.Next() {
|
|
var id uint64
|
|
var at time.Time
|
|
if err := rows.Scan(&id, &at); err != nil {
|
|
rows.Close()
|
|
return o, err
|
|
}
|
|
if i := dayIndex(days, at, loc); i >= 0 {
|
|
if seen[i] == nil {
|
|
seen[i] = map[uint64]bool{}
|
|
}
|
|
seen[i][id] = true
|
|
}
|
|
}
|
|
rows.Close()
|
|
if err := rows.Err(); err != nil {
|
|
return o, err
|
|
}
|
|
for i := range days {
|
|
days[i].Players = len(seen[i])
|
|
}
|
|
o.Days = days
|
|
|
|
o.Maps, err = s.maps(ctx, 0, &start, 10)
|
|
return o, err
|
|
}
|
|
|
|
// maps is time on T/CT per map, for one player or everyone, since a time or (nil) ever.
|
|
func (s *Store) maps(ctx context.Context, steamid uint64, since *time.Time, limit int) ([]MapTime, error) {
|
|
q := `SELECT map, SUM(seconds), COUNT(DISTINCT steamid64) FROM pt_segments
|
|
WHERE ended_at IS NOT NULL AND team IN (2, 3)`
|
|
var args []any
|
|
if since != nil {
|
|
q += " AND started_at >= ?"
|
|
args = append(args, *since)
|
|
}
|
|
if steamid != 0 {
|
|
q += " AND steamid64 = ?"
|
|
args = append(args, steamid)
|
|
}
|
|
where, args := s.and("server_id", args)
|
|
rows, err := s.db.QueryContext(ctx, q+where+` GROUP BY map ORDER BY SUM(seconds) DESC LIMIT ?`, append(args, limit)...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
out := []MapTime{}
|
|
for rows.Next() {
|
|
var m MapTime
|
|
var secs int64
|
|
if err := rows.Scan(&m.Map, &secs, &m.Players); err != nil {
|
|
return nil, err
|
|
}
|
|
m.Hours = round1(float64(secs) / 3600)
|
|
if steamid != 0 {
|
|
m.Players = 0
|
|
}
|
|
out = append(out, m)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// Leaders is the most time on T/CT since a time, or (nil) ever, with kills and deaths over the
|
|
// same stretch.
|
|
func (s *Store) Leaders(ctx context.Context, since *time.Time, limit int) ([]Leader, error) {
|
|
var rows *sql.Rows
|
|
var err error
|
|
if since == nil {
|
|
// pt_players' totals cover every server sharing the database.
|
|
rows, err = s.db.QueryContext(ctx, `SELECT steamid64, last_nickname, t_seconds, ct_seconds FROM pt_players
|
|
WHERE t_seconds + ct_seconds > 0 ORDER BY t_seconds + ct_seconds DESC LIMIT ?`, limit)
|
|
} else {
|
|
where, args := s.and("g.server_id", []any{*since})
|
|
rows, err = s.db.QueryContext(ctx, `SELECT g.steamid64, COALESCE(MAX(p.last_nickname), ''),
|
|
SUM(IF(g.team = 2, g.seconds, 0)), SUM(IF(g.team = 3, g.seconds, 0))
|
|
FROM pt_segments g LEFT JOIN pt_players p ON p.steamid64 = g.steamid64
|
|
WHERE g.ended_at IS NOT NULL AND g.team IN (2, 3) AND g.started_at >= ?`+where+`
|
|
GROUP BY g.steamid64 ORDER BY SUM(g.seconds) DESC LIMIT ?`, append(args, limit)...)
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := []Leader{}
|
|
var ids []uint64
|
|
for rows.Next() {
|
|
var id uint64
|
|
var l Leader
|
|
var t, ct int64
|
|
if err := rows.Scan(&id, &l.Name, &t, &ct); err != nil {
|
|
rows.Close()
|
|
return nil, err
|
|
}
|
|
l.SteamID, l.T, l.CT = strconv.FormatUint(id, 10), round1(float64(t)/3600), round1(float64(ct)/3600)
|
|
out = append(out, l)
|
|
ids = append(ids, id)
|
|
}
|
|
rows.Close()
|
|
if err := rows.Err(); err != nil || len(ids) == 0 {
|
|
return out, err
|
|
}
|
|
|
|
if !s.events.Load() {
|
|
return out, nil
|
|
}
|
|
kd, err := s.killsDeaths(ctx, ids, since)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
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 st Stats
|
|
if err := rows.Scan(&id, &st.Kills, &st.Deaths, &st.Headshots); err != nil {
|
|
return nil, err
|
|
}
|
|
out[id] = st
|
|
}
|
|
return out, rows.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 {
|
|
args[i] = id
|
|
}
|
|
return strings.TrimSuffix(strings.Repeat("?,", len(ids)), ","), args
|
|
}
|
|
|
|
// ParseID turns a SteamID64 string into the number PlaytimeHistory stores.
|
|
func ParseID(steamid string) (uint64, error) {
|
|
n, err := strconv.ParseUint(steamid, 10, 64)
|
|
if err != nil || n == 0 {
|
|
return 0, fmt.Errorf("bad SteamID64 %q", steamid)
|
|
}
|
|
return n, nil
|
|
}
|
|
|
|
// ---------------------------------------------------------------- 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"`
|
|
}
|
|
|
|
// 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"`
|
|
Headshots int `json:"headshots"`
|
|
}
|
|
|
|
// Player is what anyone can see about one player's playtime. Hours are on T or CT unless named.
|
|
type Player struct {
|
|
FirstSeen time.Time `json:"firstSeen"`
|
|
LastSeen time.Time `json:"lastSeen"`
|
|
Sessions int `json:"sessions"`
|
|
T float64 `json:"t"`
|
|
CT float64 `json:"ct"`
|
|
Spec float64 `json:"spec"` // spectating or unassigned
|
|
Alive float64 `json:"alive"`
|
|
Dead float64 `json:"dead"`
|
|
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"`
|
|
}
|
|
|
|
// Player returns nil when PlaytimeHistory has never seen them.
|
|
func (s *Store) Player(ctx context.Context, steamid uint64, now time.Time, loc *time.Location) (*Player, error) {
|
|
var p Player
|
|
var t, ct, spec, alive, dead int64
|
|
err := s.db.QueryRowContext(ctx, `SELECT first_seen, last_seen, sessions_count, t_seconds, ct_seconds, spec_seconds,
|
|
alive_seconds, dead_seconds FROM pt_players WHERE steamid64 = ?`, steamid).
|
|
Scan(&p.FirstSeen, &p.LastSeen, &p.Sessions, &t, &ct, &spec, &alive, &dead)
|
|
if err == sql.ErrNoRows {
|
|
return nil, nil
|
|
} else if err != nil {
|
|
return nil, err
|
|
}
|
|
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 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() {
|
|
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 {
|
|
return nil, err
|
|
}
|
|
hoursPerDay(days, buckets, loc)
|
|
p.Days = days
|
|
if p.Maps, err = s.maps(ctx, steamid, nil, 5); err != nil {
|
|
return nil, err
|
|
}
|
|
return &p, nil
|
|
}
|
|
|
|
type Nickname struct {
|
|
Name string `json:"name"`
|
|
FirstSeen time.Time `json:"firstSeen"`
|
|
LastSeen time.Time `json:"lastSeen"`
|
|
Times int `json:"times"`
|
|
}
|
|
|
|
type Session struct {
|
|
Started time.Time `json:"started"`
|
|
Ended *time.Time `json:"ended,omitempty"`
|
|
Seconds int64 `json:"seconds"`
|
|
EndKind int `json:"endKind"`
|
|
Map string `json:"map"`
|
|
EndMap string `json:"endMap,omitempty"`
|
|
Reason string `json:"reason,omitempty"`
|
|
Kills *int `json:"kills,omitempty"`
|
|
Deaths *int `json:"deaths,omitempty"`
|
|
Country string `json:"country,omitempty"`
|
|
City string `json:"city,omitempty"`
|
|
}
|
|
|
|
// 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"`
|
|
}
|
|
|
|
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)
|
|
if err != nil {
|
|
return out, err
|
|
}
|
|
for rows.Next() {
|
|
var n Nickname
|
|
if err := rows.Scan(&n.Name, &n.FirstSeen, &n.LastSeen, &n.Times); err != nil {
|
|
rows.Close()
|
|
return out, err
|
|
}
|
|
out.Names = append(out.Names, n)
|
|
}
|
|
rows.Close()
|
|
if err := rows.Err(); err != nil {
|
|
return out, err
|
|
}
|
|
|
|
// 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 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
|
|
}
|
|
if ended.Valid {
|
|
x.Ended = &ended.Time
|
|
}
|
|
x.Kills, x.Deaths = intPtr(kills), intPtr(deaths)
|
|
out.Recent = append(out.Recent, x)
|
|
}
|
|
rows.Close()
|
|
if err := rows.Err(); err != nil {
|
|
return out, err
|
|
}
|
|
|
|
err = s.db.QueryRowContext(ctx, `SELECT country_iso, COALESCE(country_name, ''), COALESCE(city, '') FROM pt_sessions
|
|
WHERE steamid64 = ? AND country_iso IS NOT NULL ORDER BY started_at DESC LIMIT 1`, steamid).
|
|
Scan(&out.Country, &out.CountryName, &out.City)
|
|
if err != nil && err != sql.ErrNoRows {
|
|
return out, err
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func intPtr(n sql.NullInt64) *int {
|
|
if !n.Valid {
|
|
return nil
|
|
}
|
|
v := int(n.Int64)
|
|
return &v
|
|
}
|
|
|
|
// ---------------------------------------------------------------- Staff page
|
|
|
|
// Online is an open session: when it started, where from, and the player's team right now.
|
|
type Online struct {
|
|
Since *time.Time `json:"since,omitempty"`
|
|
Team *int `json:"team,omitempty"`
|
|
Alive *bool `json:"alive,omitempty"`
|
|
Country string `json:"country,omitempty"`
|
|
City string `json:"city,omitempty"`
|
|
}
|
|
|
|
// Online looks up the open sessions of these players (the ones the live status lists, so admins
|
|
// hidden with css_hide stay hidden).
|
|
func (s *Store) Online(ctx context.Context, ids []uint64) (map[string]Online, error) {
|
|
out := map[string]Online{}
|
|
if len(ids) == 0 {
|
|
return out, nil
|
|
}
|
|
in, args := inList(ids)
|
|
where, args := s.and("s.server_id", args)
|
|
rows, err := s.db.QueryContext(ctx, `SELECT s.steamid64, s.started_at, g.team, g.alive, COALESCE(s.country_iso, ''), COALESCE(s.city, '')
|
|
FROM pt_sessions s LEFT JOIN pt_segments g ON g.session_key = s.session_key AND g.ended_at IS NULL
|
|
WHERE s.ended_at IS NULL AND s.steamid64 IN (`+in+`)`+where+` ORDER BY s.started_at`, args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var id uint64
|
|
var o Online
|
|
var since time.Time
|
|
var team, alive sql.NullInt64
|
|
if err := rows.Scan(&id, &since, &team, &alive, &o.Country, &o.City); err != nil {
|
|
return nil, err
|
|
}
|
|
o.Since = &since
|
|
if team.Valid {
|
|
o.Team = intPtr(team)
|
|
}
|
|
if alive.Valid {
|
|
a := alive.Int64 == 1
|
|
o.Alive = &a
|
|
}
|
|
// The latest session wins if a crash left an older one open.
|
|
out[strconv.FormatUint(id, 10)] = o
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// Activity is a staff member's time on the server since a time.
|
|
type Activity struct {
|
|
Hours float64 `json:"hours"` // T or CT
|
|
Spec float64 `json:"spec"`
|
|
LastSeen *time.Time `json:"lastSeen,omitempty"`
|
|
}
|
|
|
|
func (s *Store) Activity(ctx context.Context, ids []uint64, since time.Time) (map[string]Activity, error) {
|
|
out := map[string]Activity{}
|
|
if len(ids) == 0 {
|
|
return out, nil
|
|
}
|
|
in, args := inList(ids)
|
|
where, args := s.and("server_id", append(args, since))
|
|
rows, err := s.db.QueryContext(ctx, `SELECT steamid64, SUM(IF(team IN (2, 3), seconds, 0)), SUM(IF(team IN (0, 1), seconds, 0))
|
|
FROM pt_segments WHERE steamid64 IN (`+in+`) AND ended_at IS NOT NULL AND started_at >= ?`+where+` GROUP BY steamid64`, args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for rows.Next() {
|
|
var id uint64
|
|
var play, spec int64
|
|
if err := rows.Scan(&id, &play, &spec); err != nil {
|
|
rows.Close()
|
|
return nil, err
|
|
}
|
|
out[strconv.FormatUint(id, 10)] = Activity{Hours: round1(float64(play) / 3600), Spec: round1(float64(spec) / 3600)}
|
|
}
|
|
rows.Close()
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
in, args = inList(ids)
|
|
rows, err = s.db.QueryContext(ctx, `SELECT steamid64, last_seen FROM pt_players WHERE steamid64 IN (`+in+`)`, args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var id uint64
|
|
var seen time.Time
|
|
if err := rows.Scan(&id, &seen); err != nil {
|
|
return nil, err
|
|
}
|
|
key := strconv.FormatUint(id, 10)
|
|
a := out[key]
|
|
a.LastSeen = &seen
|
|
out[key] = a
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
type Reason struct {
|
|
Name string `json:"name"`
|
|
Count int `json:"count"`
|
|
AvgSeconds int64 `json:"avgSeconds"`
|
|
}
|
|
|
|
// Reasons counts how players disconnected since a time (map changes and crashes aren't reasons).
|
|
func (s *Store) Reasons(ctx context.Context, since time.Time) ([]Reason, error) {
|
|
where, args := s.and("server_id", []any{EndDisconnect, since})
|
|
rows, err := s.db.QueryContext(ctx, `SELECT COALESCE(disconnect_reason_name, ''), COUNT(*), COALESCE(AVG(duration_seconds), 0)
|
|
FROM pt_sessions WHERE end_kind = ? AND started_at >= ?`+where+`
|
|
GROUP BY disconnect_reason_name ORDER BY COUNT(*) DESC LIMIT 15`, args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
out := []Reason{}
|
|
for rows.Next() {
|
|
var r Reason
|
|
var avg float64
|
|
if err := rows.Scan(&r.Name, &r.Count, &avg); err != nil {
|
|
return nil, err
|
|
}
|
|
r.AvgSeconds = int64(avg)
|
|
out = append(out, r)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
type Country struct {
|
|
ISO string `json:"iso"`
|
|
Name string `json:"name"`
|
|
Players int `json:"players"`
|
|
Hours float64 `json:"hours"` // connected, any team
|
|
}
|
|
|
|
// Countries is where players connect from, ever.
|
|
func (s *Store) Countries(ctx context.Context) ([]Country, error) {
|
|
where, args := s.and("server_id", nil)
|
|
rows, err := s.db.QueryContext(ctx, `SELECT country_iso, COALESCE(MAX(country_name), ''), COUNT(DISTINCT steamid64),
|
|
COALESCE(SUM(duration_seconds), 0)
|
|
FROM pt_sessions WHERE country_iso IS NOT NULL`+where+`
|
|
GROUP BY country_iso ORDER BY COUNT(DISTINCT steamid64) DESC LIMIT 20`, args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
out := []Country{}
|
|
for rows.Next() {
|
|
var c Country
|
|
var secs int64
|
|
if err := rows.Scan(&c.ISO, &c.Name, &c.Players, &secs); err != nil {
|
|
return nil, err
|
|
}
|
|
c.Hours = round1(float64(secs) / 3600)
|
|
out = append(out, c)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// EndKinds counts how sessions since a time ended, by end_kind.
|
|
func (s *Store) EndKinds(ctx context.Context, since time.Time) (map[int]int, error) {
|
|
where, args := s.and("server_id", []any{since})
|
|
rows, err := s.db.QueryContext(ctx, `SELECT end_kind, COUNT(*) FROM pt_sessions WHERE started_at >= ?`+where+` GROUP BY end_kind`, args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
out := map[int]int{}
|
|
for rows.Next() {
|
|
var k, n int
|
|
if err := rows.Scan(&k, &n); err != nil {
|
|
return nil, err
|
|
}
|
|
out[k] = n
|
|
}
|
|
return out, rows.Err()
|
|
}
|