diff --git a/README.md b/README.md index 48bf26a..ebd4af1 100644 --- a/README.md +++ b/README.md @@ -125,9 +125,9 @@ verified players perks. It keeps everything in SimpleAdmin's database (`gv_verif it to the ranks that should have it in the rank editor), staff give overrides for a day up to a year or with no expiry, change them and remove them. Root sets the group and turns verification on and off. -- **The public site** shows nothing about verification. On the staff panel, the Overview - scoreboard shows a "Verified" tag only while verification is on (enabled, with a group set) and - GroupVerify isn't missing from the server's plugin list. +- **No Verified tags** anywhere else: not on the public site, the scoreboards or player headers. + Who's verified is on the Verification page, and why in each player drawer's Verification + section. - **Clan tag**: Root can set the scoreboard tag verified players without a rank get (GroupVerify's `clan_tag`, at most 12 characters). CS2-Tags (1.0.4c-zio6+) applies it, and gives unverified players without a rank no tag at all. Blank turns it off. diff --git a/internal/playtime/playtime.go b/internal/playtime/playtime.go index ca6c6c5..6fd74cc 100644 --- a/internal/playtime/playtime.go +++ b/internal/playtime/playtime.go @@ -150,6 +150,36 @@ func (s *Store) hours(ctx context.Context, since time.Time, steamid uint64) ([]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() @@ -230,9 +260,11 @@ type MapTime struct { // 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 + 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"` @@ -276,13 +308,20 @@ func (s *Store) Overview(ctx context.Context, now time.Time, loc *time.Location) 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 - local := b.Start.In(loc) - o.ByHour[local.Hour()] += h / float64(max(o.ByHourDays, 1)) - if wd := (int(local.Weekday()) + 6) % 7; !local.Before(heatFrom) && weekdays[wd] > 0 { - o.Heat[wd][local.Hour()] += h / float64(weekdays[wd]) - } switch { case !b.Start.Before(week): o.Summary.Hours += h @@ -290,7 +329,7 @@ func (s *Store) Overview(ctx context.Context, now time.Time, loc *time.Location) o.Summary.HoursPrev += h } } - // Two decimals: a quiet hour averages well under one player. + // 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]) } diff --git a/internal/playtime/playtime_db_test.go b/internal/playtime/playtime_db_test.go index d93bb94..129d4b2 100644 --- a/internal/playtime/playtime_db_test.go +++ b/internal/playtime/playtime_db_test.go @@ -172,12 +172,14 @@ func TestPlaytimeDB(t *testing.T) { 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. + // 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 want := 3.5 / 11; byHour < want-0.05 || byHour > want+0.05 { - t.Fatalf("byHour adds up to %.3f, want about %.3f", byHour, want) + 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 diff --git a/internal/web/admin.go b/internal/web/admin.go index e241360..0609583 100644 --- a/internal/web/admin.go +++ b/internal/web/admin.go @@ -35,7 +35,6 @@ func nonNil[T any](v []T) []T { func (s *Server) adminOverview(w http.ResponseWriter, r *http.Request, id *Identity, x *staffIndex) { ctx := r.Context() view := s.serverView(x, s.poll.Current()) - s.markVerified(ctx, view.Players) var ids []string for _, p := range view.Players { diff --git a/internal/web/assets/admin.js b/internal/web/assets/admin.js index 684d044..4ea1d68 100644 --- a/internal/web/assets/admin.js +++ b/internal/web/assets/admin.js @@ -85,7 +85,7 @@ function onlineTable(players) { PlayerRankK / DPingActions ${list.map((p) => ` -
${teamName[p.team] || "SPEC"}
${esc(p.name)} ${p.verified ? VERIFIED_TAG : ""} ${tagsFor(p)}
+
${teamName[p.team] || "SPEC"}
${esc(p.name)} ${tagsFor(p)}
${rankTag(p.rank)} ${p.kills} / ${p.deaths} ${p.ping} @@ -773,7 +773,7 @@ async function openPlayer(sid) {