K/D, headshot %, kills and deaths (drawer, Top players, staff session lists) now come from pt_events, so kills of bots and deaths to bots don't count, as in Records; damage per round and MVPs are gone, since only the game's counters have them and those include bots. Average ping and the record count footnote are no longer shown. The server header's grid now runs behind the whole page.
313 lines
12 KiB
Go
313 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 3.5 h spread over those 11 days.
|
|
if o.ByHourDays != 11 || o.HeatDays != 11 {
|
|
t.Fatalf("averaged over %d days (heatmap %d), want 11", o.ByHourDays, o.HeatDays)
|
|
}
|
|
if want := 3.5 / 11; byHour < want-0.05 || byHour > want+0.05 {
|
|
t.Fatalf("byHour adds up to %.3f, want about %.3f", byHour, want)
|
|
}
|
|
// 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")
|
|
}
|