Playtime from PlaytimeHistory's database (SAW_PT_DSN)
A public Playtime tab and player-drawer playtime, and staff-only figures (who's on, staff activity, disconnect reasons, countries, names, sessions) for @css/generic on the staff panel and on the public site when signed in. Hidden unless SAW_PT_DSN is set and its tables are there.
This commit is contained in:
parent
daa7ec2dbc
commit
6d0fc6e463
18 changed files with 2238 additions and 12 deletions
771
internal/playtime/playtime.go
Normal file
771
internal/playtime/playtime.go
Normal file
|
|
@ -0,0 +1,771 @@
|
|||
// 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"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-sql-driver/mysql"
|
||||
)
|
||||
|
||||
// Teams as PlaytimeHistory records them.
|
||||
const (
|
||||
TeamUnassigned = 0
|
||||
TeamSpectator = 1
|
||||
TeamT = 2
|
||||
TeamCT = 3
|
||||
)
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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() }
|
||||
|
||||
// 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.
|
||||
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)
|
||||
if err != nil || n < len(tables) {
|
||||
return false, false, err
|
||||
}
|
||||
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
|
||||
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)
|
||||
for _, b := range buckets {
|
||||
h := float64(b.Seconds) / 3600
|
||||
local := b.Start.In(loc)
|
||||
o.ByHour[local.Hour()] += h / 30
|
||||
if !local.Before(heatFrom) {
|
||||
o.Heat[(int(local.Weekday())+6)%7][local.Hour()] += h / 4
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
where, args := s.and("server_id", args)
|
||||
krows, err := s.db.QueryContext(ctx, q+where+" GROUP BY steamid64", args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer krows.Close()
|
||||
kd := map[string][2]int{}
|
||||
for krows.Next() {
|
||||
var id uint64
|
||||
var k, d int
|
||||
if err := krows.Scan(&id, &k, &d); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
kd[strconv.FormatUint(id, 10)] = [2]int{k, d}
|
||||
}
|
||||
for i := range out {
|
||||
out[i].Kills, out[i].Deaths = kd[out[i].SteamID][0], kd[out[i].SteamID][1]
|
||||
}
|
||||
return out, krows.Err()
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
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.
|
||||
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"`
|
||||
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 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
|
||||
}
|
||||
|
||||
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"`
|
||||
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.
|
||||
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) {
|
||||
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
|
||||
}
|
||||
|
||||
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...)
|
||||
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,
|
||||
&x.Country, &x.City); err != nil {
|
||||
rows.Close()
|
||||
return out, err
|
||||
}
|
||||
if ended.Valid {
|
||||
x.Ended = &ended.Time
|
||||
}
|
||||
x.Kills, x.Deaths, x.Ping = intPtr(kills), intPtr(deaths), intPtr(ping)
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
262
internal/playtime/playtime_db_test.go
Normal file
262
internal/playtime/playtime_db_test.go
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
package playtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
_ "embed"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-sql-driver/mysql"
|
||||
)
|
||||
|
||||
//go:embed testdata/schema.sql
|
||||
var schema string
|
||||
|
||||
// TestPlaytimeDB runs every query against a real MySQL/MariaDB holding PlaytimeHistory's schema
|
||||
// (testdata/schema.sql). It drops and recreates the pt_ tables, so only point it at a scratch
|
||||
// database:
|
||||
//
|
||||
// SAW_TEST_PT_DSN='root:test@tcp(127.0.0.1:33071)/playtime_history' go test ./internal/playtime -run DB
|
||||
func TestPlaytimeDB(t *testing.T) {
|
||||
dsn := os.Getenv("SAW_TEST_PT_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("SAW_TEST_PT_DSN not set")
|
||||
}
|
||||
cfg, err := mysql.ParseDSN(dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg.ParseTime, cfg.Loc = true, time.UTC
|
||||
db, err := sql.Open("mysql", cfg.FormatDSN())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
exec := func(q string, args ...any) {
|
||||
t.Helper()
|
||||
if _, err := db.Exec(q, args...); err != nil {
|
||||
t.Fatalf("%s: %v", q, err)
|
||||
}
|
||||
}
|
||||
exec("DROP VIEW IF EXISTS pt_segments_hourly")
|
||||
for _, table := range tables {
|
||||
exec("DROP TABLE IF EXISTS " + table)
|
||||
}
|
||||
exec("DROP TABLE IF EXISTS pt_servers")
|
||||
ctx := context.Background()
|
||||
st := New(db, 1)
|
||||
if ok, _, err := st.State(ctx); err != nil || ok {
|
||||
t.Fatalf("State without tables: %v, %v", ok, err)
|
||||
}
|
||||
for stmt := range strings.SplitSeq(schema, ";\n") {
|
||||
if code := strings.TrimSpace(stripComments(stmt)); code != "" {
|
||||
exec(code)
|
||||
}
|
||||
}
|
||||
if ok, data, err := st.State(ctx); err != nil || !ok || data {
|
||||
t.Fatalf("State with empty tables: %v, %v, %v", ok, data, err)
|
||||
}
|
||||
|
||||
// Times are whole seconds at a few minutes past the hour, so segments cross hour boundaries.
|
||||
now := time.Now().UTC().Truncate(time.Hour).Add(-50 * time.Minute)
|
||||
ago := func(d time.Duration) time.Time { return now.Add(-d) }
|
||||
const a, b = uint64(76561198000001001), uint64(76561198000001002)
|
||||
exec(`INSERT INTO pt_players (steamid64, first_seen, last_seen, sessions_count, total_seconds, t_seconds, ct_seconds,
|
||||
spec_seconds, alive_seconds, dead_seconds, last_nickname, last_country, last_server_id)
|
||||
VALUES (?, ?, ?, 1, 5400, 3600, 1800, 1800, 4000, 1400, 'Alpha', 'GB', 1),
|
||||
(?, ?, ?, 2, 7200, 7200, 0, 0, 7200, 0, 'Bravo', 'DE', 1)`,
|
||||
a, ago(3*time.Hour), ago(time.Hour), b, ago(10*24*time.Hour), ago(10*24*time.Hour-2*time.Hour))
|
||||
exec(`INSERT INTO pt_nicknames (steamid64, nickname, first_seen, last_seen, times_seen)
|
||||
VALUES (?, 'Alpha', ?, ?, 3), (?, 'alpha_old', ?, ?, 1)`, a, ago(3*time.Hour), ago(time.Hour), a, ago(5*time.Hour), ago(4*time.Hour))
|
||||
session := func(key string, id uint64, start time.Time, end *time.Time, kind int, reason string, kills, deaths int) {
|
||||
var dur any
|
||||
if end != nil {
|
||||
dur = int(end.Sub(start).Seconds())
|
||||
}
|
||||
hb := start
|
||||
if end != nil {
|
||||
hb = *end
|
||||
}
|
||||
exec(`INSERT INTO pt_sessions (session_key, steamid64, server_id, nickname, started_at, ended_at, heartbeat_at,
|
||||
duration_seconds, end_kind, connect_map, disconnect_map, disconnect_reason_name, players_online, max_players,
|
||||
country_iso, country_name, city, kills, deaths, rounds_played, damage, ping_avg, plugin_version)
|
||||
VALUES (?, ?, 1, 'x', ?, ?, ?, ?, ?, 'de_mirage', 'de_mirage', NULLIF(?, ''), 2, 10, 'GB', 'United Kingdom', 'London',
|
||||
?, ?, 10, 900, 20, '0.1.0')`, key, id, start, end, hb, dur, kind, reason, kills, deaths)
|
||||
}
|
||||
segment := func(key, sess string, id uint64, team int, start time.Time, end *time.Time) {
|
||||
var secs any
|
||||
if end != nil {
|
||||
secs = int(end.Sub(start).Seconds())
|
||||
}
|
||||
alive := any(nil)
|
||||
if team >= TeamT {
|
||||
alive = 1
|
||||
}
|
||||
exec(`INSERT INTO pt_segments (segment_key, session_key, steamid64, server_id, map, team, alive, started_at, ended_at,
|
||||
heartbeat_at, seconds) VALUES (?, ?, ?, 1, 'de_mirage', ?, ?, ?, ?, ?, ?)`, key, sess, id, team, alive, start, end, start, secs)
|
||||
}
|
||||
at := func(d time.Duration) *time.Time { x := ago(d); return &x }
|
||||
session("a1", a, ago(3*time.Hour), at(time.Hour), EndDisconnect, "NETWORK_DISCONNECT_DISCONNECT_BY_USER", 10, 5)
|
||||
segment("a1-1", "a1", a, TeamT, ago(3*time.Hour), at(2*time.Hour))
|
||||
segment("a1-2", "a1", a, TeamCT, ago(2*time.Hour), at(90*time.Minute))
|
||||
segment("a1-3", "a1", a, TeamSpectator, ago(90*time.Minute), at(time.Hour))
|
||||
session("b1", b, ago(10*24*time.Hour), at(10*24*time.Hour-2*time.Hour), EndMapChange, "", 3, 3)
|
||||
segment("b1-1", "b1", b, TeamT, ago(10*24*time.Hour), at(10*24*time.Hour-2*time.Hour))
|
||||
session("b2", b, ago(30*time.Minute), nil, EndOpen, "", 0, 0)
|
||||
segment("b2-1", "b2", b, TeamCT, ago(30*time.Minute), nil)
|
||||
// Another server's session, which a store for server 1 never counts.
|
||||
exec(`INSERT INTO pt_sessions (session_key, steamid64, server_id, nickname, started_at, heartbeat_at, end_kind, connect_map,
|
||||
players_online, max_players, plugin_version) VALUES ('other', ?, 2, 'x', ?, ?, 1, 'de_dust2', 1, 10, '0.1.0')`, a, ago(time.Hour), ago(time.Hour))
|
||||
exec(`INSERT INTO pt_online_snapshots (server_id, taken_at, players, bots, max_players, map)
|
||||
VALUES (1, ?, 5, 0, 10, 'de_mirage'), (1, ?, 9, 0, 10, 'de_mirage'), (2, ?, 20, 0, 10, 'de_dust2')`,
|
||||
ago(24*time.Hour), ago(48*time.Hour), ago(24*time.Hour))
|
||||
|
||||
if ok, data, err := st.State(ctx); err != nil || !ok || !data {
|
||||
t.Fatalf("State with data: %v, %v, %v", ok, data, err)
|
||||
}
|
||||
|
||||
// The hour split must add up to exactly the segments' lengths.
|
||||
buckets, err := st.hours(ctx, ago(30*24*time.Hour), a)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var total int64
|
||||
for _, bk := range buckets {
|
||||
total += bk.Seconds
|
||||
}
|
||||
if total != 5400 || len(buckets) < 2 {
|
||||
t.Fatalf("Alpha's buckets add up to %d s over %d hours, want 5400 s over 2+", total, len(buckets))
|
||||
}
|
||||
// Clamped to the start of the window: only the 30 min since.
|
||||
if buckets, err = st.hours(ctx, ago(150*time.Minute), a); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
total = 0
|
||||
for _, bk := range buckets {
|
||||
total += bk.Seconds
|
||||
}
|
||||
if total != 3600 {
|
||||
t.Fatalf("since 2.5 h ago: %d s, want 3600 (30 min of T, 30 of CT)", total)
|
||||
}
|
||||
|
||||
london, _ := time.LoadLocation("Europe/London")
|
||||
o, err := st.Overview(ctx, now, london)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := o.Summary
|
||||
if s.Players != 2 || s.PlayersPrev != 1 || s.Hours != 1.5 || s.HoursPrev != 2 || s.Peak != 9 || s.NewPlayers != 1 {
|
||||
t.Fatalf("summary %+v", s)
|
||||
}
|
||||
var byHour float64
|
||||
for _, h := range o.ByHour {
|
||||
byHour += h
|
||||
}
|
||||
if byHour < 3.3/30 || byHour > 3.7/30 { // 3.5 h spread over 30 days, rounded per hour
|
||||
t.Fatalf("byHour adds up to %.3f, want about %.3f", byHour, 3.5/30)
|
||||
}
|
||||
// Alpha on one day, Bravo on two days ten days apart.
|
||||
players := 0
|
||||
for _, d := range o.Days {
|
||||
players += d.Players
|
||||
}
|
||||
if len(o.Days) != 30 || players != 3 {
|
||||
t.Fatalf("%d players over %d days", players, len(o.Days))
|
||||
}
|
||||
if len(o.Maps) != 1 || o.Maps[0].Map != "de_mirage" || o.Maps[0].Hours != 3.5 || o.Maps[0].Players != 2 {
|
||||
t.Fatalf("maps %+v", o.Maps)
|
||||
}
|
||||
|
||||
week := ago(7 * 24 * time.Hour)
|
||||
leaders, err := st.Leaders(ctx, &week, 10)
|
||||
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 {
|
||||
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 {
|
||||
t.Fatalf("leaders all time %+v", leaders)
|
||||
}
|
||||
|
||||
p, err := st.Player(ctx, a, now, london)
|
||||
if err != nil || p == nil {
|
||||
t.Fatalf("Player: %v, %v", p, err)
|
||||
}
|
||||
var days float64
|
||||
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 {
|
||||
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)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
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 {
|
||||
t.Fatalf("staff %+v", ps)
|
||||
}
|
||||
|
||||
on, err := st.Online(ctx, []uint64{a, b})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(on) != 1 || on["76561198000001002"].Team == nil || *on["76561198000001002"].Team != TeamCT {
|
||||
t.Fatalf("online %+v", on)
|
||||
}
|
||||
|
||||
act, err := st.Activity(ctx, []uint64{a, b}, week)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if x := act["76561198000001001"]; x.Hours != 1.5 || x.Spec != 0.5 || x.LastSeen == nil {
|
||||
t.Fatalf("activity %+v", act)
|
||||
}
|
||||
if x := act["76561198000001002"]; x.Hours != 0 || x.LastSeen == nil {
|
||||
t.Fatalf("Bravo's activity %+v", x)
|
||||
}
|
||||
|
||||
reasons, err := st.Reasons(ctx, ago(30*24*time.Hour))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(reasons) != 1 || reasons[0].Count != 1 || reasons[0].AvgSeconds != 7200 {
|
||||
t.Fatalf("reasons %+v", reasons)
|
||||
}
|
||||
countries, err := st.Countries(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(countries) != 1 || countries[0].ISO != "GB" || countries[0].Players != 2 {
|
||||
t.Fatalf("countries %+v", countries)
|
||||
}
|
||||
ends, err := st.EndKinds(ctx, ago(30*24*time.Hour))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ends[EndDisconnect] != 1 || ends[EndMapChange] != 1 || ends[EndOpen] != 1 {
|
||||
t.Fatalf("end kinds %+v", ends)
|
||||
}
|
||||
}
|
||||
|
||||
func stripComments(stmt string) string {
|
||||
var out []string
|
||||
for l := range strings.SplitSeq(stmt, "\n") {
|
||||
if !strings.HasPrefix(strings.TrimSpace(l), "--") {
|
||||
out = append(out, l)
|
||||
}
|
||||
}
|
||||
return strings.Join(out, "\n")
|
||||
}
|
||||
44
internal/playtime/playtime_test.go
Normal file
44
internal/playtime/playtime_test.go
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
package playtime
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Days and hour buckets follow the viewer's time zone, not UTC.
|
||||
func TestLastDaysInZone(t *testing.T) {
|
||||
london, err := time.LoadLocation("Europe/London")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 00:30 UTC is 01:30 in London (BST): already the 4th there.
|
||||
now := time.Date(2026, 10, 4, 0, 30, 0, 0, time.UTC)
|
||||
days, start := lastDays(now, london, 30)
|
||||
if len(days) != 30 || days[0].Date != "2026-09-05" || days[29].Date != "2026-10-04" {
|
||||
t.Fatalf("days run %s to %s (%d)", days[0].Date, days[len(days)-1].Date, len(days))
|
||||
}
|
||||
if want := time.Date(2026, 9, 5, 0, 0, 0, 0, london); !start.Equal(want) {
|
||||
t.Fatalf("start %v, want %v", start, want)
|
||||
}
|
||||
|
||||
// 23:00 UTC on the 3rd is midnight on the 4th in London.
|
||||
hoursPerDay(days, []Bucket{
|
||||
{Start: time.Date(2026, 10, 3, 23, 0, 0, 0, time.UTC), Seconds: 3600},
|
||||
{Start: time.Date(2026, 10, 3, 22, 0, 0, 0, time.UTC), Seconds: 1800},
|
||||
{Start: time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC), Seconds: 3600}, // before the window
|
||||
}, london)
|
||||
if days[29].Hours != 1 || days[28].Hours != 0.5 {
|
||||
t.Fatalf("4th %.1f h, 3rd %.1f h; want 1 and 0.5", days[29].Hours, days[28].Hours)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseID(t *testing.T) {
|
||||
if n, err := ParseID("76561198164949547"); err != nil || n != 76561198164949547 {
|
||||
t.Fatalf("got %d, %v", n, err)
|
||||
}
|
||||
for _, bad := range []string{"", "0", "-1", "abc", "765611981649495470000"} {
|
||||
if _, err := ParseID(bad); err == nil {
|
||||
t.Errorf("%q parsed", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
141
internal/playtime/testdata/schema.sql
vendored
Normal file
141
internal/playtime/testdata/schema.sql
vendored
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
-- A copy of PlaytimeHistory's Migrations/001_CreateTables.sql (plugins/PlaytimeHistory, 0.1.0), for
|
||||
-- playtime_db_test.go. Keep it in step with the plugin's migrations.
|
||||
|
||||
-- All times are UTC. Tables are prefixed pt_ so they can share a database if ever needed.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `pt_servers` (
|
||||
`id` int NOT NULL,
|
||||
`address` varchar(64) NOT NULL DEFAULT '',
|
||||
`hostname` varchar(128) NOT NULL DEFAULT '',
|
||||
`first_seen` datetime NOT NULL,
|
||||
`last_seen` datetime NOT NULL,
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- One row per connection, opened on join and closed on leave or map change.
|
||||
-- end_kind: 0 open, 1 disconnect, 2 map change, 3 plugin unload, 4 stale (closed at its last
|
||||
-- heartbeat after the server died).
|
||||
CREATE TABLE IF NOT EXISTS `pt_sessions` (
|
||||
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
|
||||
`session_key` char(32) NOT NULL,
|
||||
`steamid64` bigint unsigned NOT NULL,
|
||||
`server_id` int NOT NULL,
|
||||
`nickname` varchar(128) NOT NULL,
|
||||
`started_at` datetime NOT NULL,
|
||||
`ended_at` datetime NULL,
|
||||
`heartbeat_at` datetime NOT NULL,
|
||||
`duration_seconds` int unsigned NULL,
|
||||
`end_kind` tinyint NOT NULL DEFAULT 0,
|
||||
`connect_map` varchar(64) NOT NULL,
|
||||
`disconnect_map` varchar(64) NULL,
|
||||
`disconnect_reason` smallint NULL,
|
||||
`disconnect_reason_name` varchar(64) NULL,
|
||||
`players_online` smallint unsigned NOT NULL,
|
||||
`max_players` smallint unsigned NOT NULL,
|
||||
`country_iso` char(2) NULL,
|
||||
`country_name` varchar(64) NULL,
|
||||
`city` varchar(128) NULL,
|
||||
`kills` int NULL,
|
||||
`deaths` int NULL,
|
||||
`assists` int NULL,
|
||||
`headshots` int NULL,
|
||||
`damage` int NULL,
|
||||
`mvp` int NULL,
|
||||
`score` int NULL,
|
||||
`rounds_played` smallint unsigned NULL,
|
||||
`ping_avg` smallint unsigned NULL,
|
||||
`ping_min` smallint unsigned NULL,
|
||||
`ping_max` smallint unsigned NULL,
|
||||
`ping_samples` int unsigned NULL,
|
||||
`plugin_version` varchar(32) NOT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uq_session_key` (`session_key`),
|
||||
KEY `ix_steamid_started` (`steamid64`, `started_at`),
|
||||
KEY `ix_server_started` (`server_id`, `started_at`),
|
||||
KEY `ix_started` (`started_at`),
|
||||
KEY `ix_server_open` (`server_id`, `ended_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- A stretch of a session where the player's team and alive/dead state didn't change.
|
||||
-- team: 0 unassigned, 1 spectator, 2 T, 3 CT. alive: NULL when not on T/CT.
|
||||
CREATE TABLE IF NOT EXISTS `pt_segments` (
|
||||
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
|
||||
`segment_key` char(32) NOT NULL,
|
||||
`session_key` char(32) NOT NULL,
|
||||
`steamid64` bigint unsigned NOT NULL,
|
||||
`server_id` int NOT NULL,
|
||||
`map` varchar(64) NOT NULL,
|
||||
`team` tinyint NOT NULL,
|
||||
`alive` tinyint NULL,
|
||||
`started_at` datetime NOT NULL,
|
||||
`ended_at` datetime NULL,
|
||||
`heartbeat_at` datetime NOT NULL,
|
||||
`seconds` int unsigned NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uq_segment_key` (`segment_key`),
|
||||
KEY `ix_steamid_started` (`steamid64`, `started_at`),
|
||||
KEY `ix_started` (`started_at`),
|
||||
KEY `ix_session` (`session_key`),
|
||||
KEY `ix_server_open` (`server_id`, `ended_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Running totals, added to in the same transaction that closes a segment or session.
|
||||
-- spec_seconds covers both spectator and unassigned. total_seconds includes it only when the
|
||||
-- plugin's CountSpectatorTime is on.
|
||||
CREATE TABLE IF NOT EXISTS `pt_players` (
|
||||
`steamid64` bigint unsigned NOT NULL,
|
||||
`first_seen` datetime NOT NULL,
|
||||
`last_seen` datetime NOT NULL,
|
||||
`sessions_count` int unsigned NOT NULL DEFAULT 0,
|
||||
`total_seconds` bigint unsigned NOT NULL DEFAULT 0,
|
||||
`t_seconds` bigint unsigned NOT NULL DEFAULT 0,
|
||||
`ct_seconds` bigint unsigned NOT NULL DEFAULT 0,
|
||||
`spec_seconds` bigint unsigned NOT NULL DEFAULT 0,
|
||||
`alive_seconds` bigint unsigned NOT NULL DEFAULT 0,
|
||||
`dead_seconds` bigint unsigned NOT NULL DEFAULT 0,
|
||||
`last_nickname` varchar(128) NOT NULL,
|
||||
`last_country` char(2) NULL,
|
||||
`last_server_id` int NOT NULL,
|
||||
PRIMARY KEY (`steamid64`),
|
||||
KEY `ix_total` (`total_seconds`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `pt_nicknames` (
|
||||
`steamid64` bigint unsigned NOT NULL,
|
||||
`nickname` varchar(128) NOT NULL,
|
||||
`first_seen` datetime NOT NULL,
|
||||
`last_seen` datetime NOT NULL,
|
||||
`times_seen` int unsigned NOT NULL DEFAULT 1,
|
||||
PRIMARY KEY (`steamid64`, `nickname`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `pt_online_snapshots` (
|
||||
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
|
||||
`server_id` int NOT NULL,
|
||||
`taken_at` datetime NOT NULL,
|
||||
`players` smallint unsigned NOT NULL,
|
||||
`bots` smallint unsigned NOT NULL,
|
||||
`max_players` smallint unsigned NOT NULL,
|
||||
`map` varchar(64) NOT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `ix_server_taken` (`server_id`, `taken_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Segments split at the hour boundaries: one row per segment per UTC hour it touches. Closed
|
||||
-- segments only; filter on hour_start (and steamid64) when reading.
|
||||
CREATE VIEW IF NOT EXISTS `pt_segments_hourly` AS
|
||||
WITH RECURSIVE h AS (
|
||||
SELECT s.steamid64, s.server_id, s.map, s.team, s.alive, s.ended_at,
|
||||
CAST(DATE_FORMAT(s.started_at, '%Y-%m-%d %H:00:00') AS DATETIME) AS hour_start,
|
||||
s.started_at AS piece_start
|
||||
FROM pt_segments s
|
||||
WHERE s.ended_at IS NOT NULL AND s.ended_at > s.started_at
|
||||
UNION ALL
|
||||
SELECT steamid64, server_id, map, team, alive, ended_at,
|
||||
hour_start + INTERVAL 1 HOUR, hour_start + INTERVAL 1 HOUR
|
||||
FROM h
|
||||
WHERE hour_start + INTERVAL 1 HOUR < ended_at
|
||||
)
|
||||
SELECT steamid64, server_id, map, team, alive, hour_start,
|
||||
TIMESTAMPDIFF(SECOND, piece_start, LEAST(ended_at, hour_start + INTERVAL 1 HOUR)) AS seconds
|
||||
FROM h;
|
||||
Loading…
Add table
Add a link
Reference in a new issue