Busiest hours and the heatmap count the different players on T or CT in each hour, open segments included, instead of averaging player-hours, so one player for part of an hour is 1, not 0.38. Their descriptions end in the span covered (Last 30 days, or Since <date>). Side-by-side chart panels match heights, and Players per day notes its busiest day. Dropped the page descriptions on Bans, Gags & mutes, Staff and Playtime, and the Verified badges on the staff Overview scoreboard and drawer header; the Verification page and the drawer's Verification section still show it. The GroupVerify DB test now checks the staff drawer, since the public site says nothing about verification.
315 lines
12 KiB
Go
315 lines
12 KiB
Go
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))
|
|
|
|
// Events: Alpha plants twice (once 10 days ago), defuses once and knifes a player and a bot;
|
|
// Bravo gets a Zeus kill and a headshot.
|
|
// Kills say who died (nil for a bot) and the killer's session.
|
|
event := func(id uint64, sess string, kind int, at time.Time, weapon any, hs any, victim any, bot any) {
|
|
t.Helper()
|
|
exec(`INSERT INTO pt_events (event_key, server_id, session_key, steamid64, kind, at, map, team, weapon, headshot,
|
|
victim_steamid64, victim_bot)
|
|
VALUES (REPLACE(UUID(), '-', ''), 1, NULLIF(?, ''), ?, ?, ?, 'de_mirage', 2, ?, ?, ?, ?)`, sess, id, kind, at, weapon, hs, victim, bot)
|
|
}
|
|
event(a, "", EventPlanted, ago(10*24*time.Hour), nil, nil, nil, nil)
|
|
event(a, "a1", EventPlanted, ago(2*time.Hour), nil, nil, nil, nil)
|
|
event(a, "a1", EventDefused, ago(2*time.Hour), nil, nil, nil, nil)
|
|
event(a, "a1", EventKill, ago(2*time.Hour), "knife_karambit", 0, b, 0)
|
|
event(a, "a1", EventKill, ago(2*time.Hour), "knife", 1, nil, 1) // a bot: doesn't count, not even as a headshot
|
|
event(b, "b2", EventKill, ago(20*time.Minute), "taser", 0, a, 0)
|
|
event(b, "b2", EventKill, ago(20*time.Minute), "ak47", 1, a, 0)
|
|
|
|
if ok, data, err := st.State(ctx); err != nil || !ok || !data || !st.HasEvents() {
|
|
t.Fatalf("State with data: %v, %v, %v (events %v)", ok, data, err, st.HasEvents())
|
|
}
|
|
|
|
// 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
|
|
}
|
|
// Recording began with Bravo's session 10 days ago, so over those 11 days. Each player counts
|
|
// once per hour they were on T or CT in: Alpha in 2 hours, Bravo in 3 ten days ago and in 2 or
|
|
// 3 now (the open segment runs to the database's clock).
|
|
if o.ByHourDays != 11 || o.HeatDays != 11 {
|
|
t.Fatalf("averaged over %d days (heatmap %d), want 11", o.ByHourDays, o.HeatDays)
|
|
}
|
|
if n := byHour * 11; n < 6.8 || n > 8.2 {
|
|
t.Fatalf("%.2f player-hours over the days, want 7 or 8", n)
|
|
}
|
|
// 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 != 1 || leaders[0].Deaths != 2 {
|
|
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 != 2 || leaders[0].Deaths != 1 {
|
|
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 == nil || *p.Stats != (Stats{Kills: 1, Deaths: 2}) || 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)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Alpha's knife kill was in a1; Bravo killed Alpha twice, but after a1 ended.
|
|
if r := ps.Recent; len(r) != 1 || r[0].Kills == nil || *r[0].Kills != 1 || r[0].Deaths == nil || *r[0].Deaths != 0 {
|
|
t.Fatalf("recent session kills and deaths %+v", ps.Recent)
|
|
}
|
|
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" {
|
|
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)
|
|
}
|
|
|
|
rec, err := st.Records(ctx, &week, 5)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
one := func(name string, got []Record, who string, n int) {
|
|
t.Helper()
|
|
if len(got) != 1 || got[0].Name != who || got[0].Count != n {
|
|
t.Fatalf("%s: %+v, want %s with %d", name, got, who, n)
|
|
}
|
|
}
|
|
one("plants", rec.Plants, "Alpha", 1)
|
|
one("defuses", rec.Defuses, "Alpha", 1)
|
|
one("knife kills", rec.KnifeKills, "Alpha", 1)
|
|
one("zeus kills", rec.ZeusKills, "Bravo", 1)
|
|
one("headshots", rec.Headshots, "Bravo", 1)
|
|
if rec.Since == nil || !rec.Since.Equal(ago(10*24*time.Hour)) {
|
|
t.Fatalf("records since %v", rec.Since)
|
|
}
|
|
if rec, err = st.Records(ctx, nil, 5); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
one("plants ever", rec.Plants, "Alpha", 2)
|
|
if e := p.Events; e == nil || e.Plants != 2 || e.Defuses != 1 || e.KnifeKills != 1 || e.ZeusKills != 0 || e.Headshots != 0 {
|
|
t.Fatalf("Alpha's events %+v", e)
|
|
}
|
|
|
|
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")
|
|
}
|