Records: the most bomb plants, defuses, knife, Zeus and headshot kills for the last 7 days, 30 days or all time, and each player's counts, when PlaytimeHistory 0.2.0's pt_events is there. The public site's top bar now sticks the whole way down, Playtime comes after Server, and only the player drawer scrolls while it's open.
303 lines
11 KiB
Go
303 lines
11 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.
|
|
event := func(id uint64, kind int, at time.Time, weapon any, hs any, bot any) {
|
|
t.Helper()
|
|
exec(`INSERT INTO pt_events (event_key, server_id, steamid64, kind, at, map, team, weapon, headshot, victim_bot)
|
|
VALUES (REPLACE(UUID(), '-', ''), 1, ?, ?, ?, 'de_mirage', 2, ?, ?, ?)`, id, kind, at, weapon, hs, bot)
|
|
}
|
|
event(a, EventPlanted, ago(2*time.Hour), nil, nil, nil)
|
|
event(a, EventPlanted, ago(10*24*time.Hour), nil, nil, nil)
|
|
event(a, EventDefused, ago(2*time.Hour), nil, nil, nil)
|
|
event(a, EventKill, ago(2*time.Hour), "knife_karambit", 0, 0)
|
|
event(a, EventKill, ago(2*time.Hour), "knife", 1, 1) // a bot: doesn't count, not even as a headshot
|
|
event(b, EventKill, ago(20*time.Minute), "taser", 0, 0)
|
|
event(b, EventKill, ago(20*time.Minute), "ak47", 1, 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
|
|
}
|
|
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)
|
|
}
|
|
|
|
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")
|
|
}
|