// 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() } // present lists who was on T or CT in each UTC hour since a time, once per player per hour, // including the segments still open (up to now). func (s *Store) present(ctx context.Context, since time.Time) ([]Bucket, []uint64, error) { where, args := s.and("server_id", []any{since, since, since.Add(-24 * time.Hour)}) rows, err := s.db.QueryContext(ctx, `WITH RECURSIVE seg AS ( SELECT steamid64, GREATEST(started_at, ?) AS t0, COALESCE(ended_at, UTC_TIMESTAMP()) AS t1 FROM pt_segments WHERE team IN (2, 3) AND COALESCE(ended_at, UTC_TIMESTAMP()) > ? AND started_at >= ?`+where+` ), h AS ( SELECT steamid64, t1, CAST(DATE_FORMAT(t0, '%Y-%m-%d %H:00:00') AS DATETIME) AS b FROM seg WHERE t1 > t0 UNION ALL SELECT steamid64, t1, b + INTERVAL 1 HOUR FROM h WHERE b + INTERVAL 1 HOUR < t1 ) SELECT DISTINCT b, steamid64 FROM h`, args...) if err != nil { return nil, nil, err } defer rows.Close() var hours []Bucket var ids []uint64 for rows.Next() { var b Bucket var id uint64 if err := rows.Scan(&b.Start, &id); err != nil { return nil, nil, err } hours, ids = append(hours, b), append(ids, id) } return hours, ids, 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"` // How many different players were on T or CT in each local hour of the day, on average per // day; Heat is the same per weekday, Monday first. ByHour [24]float64 `json:"byHour"` Heat [7][24]float64 `json:"heat"` // 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++ } // Each player counts once for every hour they were on T or CT in. present, _, err := s.present(ctx, start) if err != nil { return o, err } for _, b := range present { local := b.Start.In(loc) o.ByHour[local.Hour()] += 1 / float64(max(o.ByHourDays, 1)) if wd := (int(local.Weekday()) + 6) % 7; !local.Before(heatFrom) && weekdays[wd] > 0 { o.Heat[wd][local.Hour()] += 1 / float64(weekdays[wd]) } } for _, b := range buckets { h := float64(b.Seconds) / 3600 switch { case !b.Start.Before(week): o.Summary.Hours += h case !b.Start.Before(fortnight): o.Summary.HoursPrev += h } } // Two decimals: averaged over days, a quiet hour is a fraction of a 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() }