Playtime stats between players only; no ping; page-wide grid
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.
This commit is contained in:
parent
c7fbff0e43
commit
2516759fa9
6 changed files with 120 additions and 81 deletions
|
|
@ -169,12 +169,14 @@ SAW_PT_DSN=pt_web:<password>@tcp(<db-host>:3306)/playtime_history
|
||||||
PlaytimeHistory 0.2.0+ (its `pt_events` table), also Records: the most bomb plants, defuses,
|
PlaytimeHistory 0.2.0+ (its `pt_events` table), also Records: the most bomb plants, defuses,
|
||||||
knife kills, Zeus kills and headshot kills for the same periods, counted as they happen, never in
|
knife kills, Zeus kills and headshot kills for the same periods, counted as they happen, never in
|
||||||
warmup, and not counting kills of bots. Player pages get time played with the T/CT/spectating
|
warmup, and not counting kills of bots. Player pages get time played with the T/CT/spectating
|
||||||
split, the last 30 days, K/D, headshots, damage per round, MVPs, their record counts and
|
split, the last 30 days, K/D, headshot %, kills and deaths, their record counts and
|
||||||
favourite maps.
|
favourite maps. Kills, deaths and headshots (here, on Top players and in staff session lists)
|
||||||
|
are between players only, from `pt_events`: kills of bots, deaths to bots and suicides don't
|
||||||
|
count, so they start with PlaytimeHistory 0.2.0.
|
||||||
- **Staff** with `@css/generic` (PlaytimeHistory's own default for looking up others) get, on the
|
- **Staff** with `@css/generic` (PlaytimeHistory's own default for looking up others) get, on the
|
||||||
staff panel's Playtime page and on the public site while signed in: who's on now with how long,
|
staff panel's Playtime page and on the public site while signed in: who's on now with how long,
|
||||||
team, ping and location; each staff member's time in the last 7 or 30 days; disconnect reasons;
|
team, ping and location; each staff member's time in the last 7 or 30 days; disconnect reasons;
|
||||||
countries; and in player drawers, the names they've used, recent sessions, location and ping.
|
countries; and in player drawers, the names they've used, recent sessions and location.
|
||||||
Root also sees whether the plugin is loaded and what `css_pt_status` says. Who's on comes from
|
Root also sees whether the plugin is loaded and what `css_pt_status` says. Who's on comes from
|
||||||
the live status, so admins hidden with `css_hide` stay hidden.
|
the live status, so admins hidden with `css_hide` stay hidden.
|
||||||
- **Days and hours of the day** are in the viewer's own time zone (the browser sends it). An hour
|
- **Days and hours of the day** are in the viewer's own time zone (the browser sends it). An hour
|
||||||
|
|
|
||||||
|
|
@ -431,31 +431,63 @@ func (s *Store) Leaders(ctx context.Context, since *time.Time, limit int) ([]Lea
|
||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
|
|
||||||
in, args := inList(ids)
|
if !s.events.Load() {
|
||||||
q := `SELECT steamid64, COALESCE(SUM(kills), 0), COALESCE(SUM(deaths), 0) FROM pt_sessions WHERE steamid64 IN (` + in + `)`
|
return out, nil
|
||||||
if since != nil {
|
|
||||||
q += " AND started_at >= ?"
|
|
||||||
args = append(args, *since)
|
|
||||||
}
|
}
|
||||||
where, args := s.and("server_id", args)
|
kd, err := s.killsDeaths(ctx, ids, since)
|
||||||
krows, err := s.db.QueryContext(ctx, q+where+" GROUP BY steamid64", args...)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer krows.Close()
|
for i, id := range ids {
|
||||||
kd := map[string][2]int{}
|
out[i].Kills, out[i].Deaths = kd[id].Kills, kd[id].Deaths
|
||||||
for krows.Next() {
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// killsDeaths counts kills and deaths between players from pt_events, since a time or (nil)
|
||||||
|
// ever. Kills of bots and deaths to bots, the world or themselves don't count: only a player
|
||||||
|
// killing another player is logged with a victim.
|
||||||
|
func (s *Store) killsDeaths(ctx context.Context, ids []uint64, since *time.Time) (map[uint64]Stats, error) {
|
||||||
|
in, ids1 := inList(ids)
|
||||||
|
_, ids2 := inList(ids)
|
||||||
|
when := ""
|
||||||
|
var args []any
|
||||||
|
part := func(idArgs []any) []any {
|
||||||
|
a := append([]any{}, idArgs...)
|
||||||
|
if since != nil {
|
||||||
|
a = append(a, *since)
|
||||||
|
}
|
||||||
|
if s.server != 0 {
|
||||||
|
a = append(a, s.server)
|
||||||
|
}
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
if since != nil {
|
||||||
|
when = " AND at >= ?"
|
||||||
|
}
|
||||||
|
srv, _ := s.and("server_id", nil)
|
||||||
|
args = append(part(ids1), part(ids2)...)
|
||||||
|
rows, err := s.db.QueryContext(ctx, `SELECT id, SUM(k), SUM(d), SUM(h) FROM (
|
||||||
|
SELECT steamid64 AS id, 1 AS k, 0 AS d, IF(headshot, 1, 0) AS h FROM pt_events
|
||||||
|
WHERE kind = 1 AND NOT victim_bot AND steamid64 IN (`+in+`)`+when+srv+`
|
||||||
|
UNION ALL
|
||||||
|
SELECT victim_steamid64, 0, 1, 0 FROM pt_events
|
||||||
|
WHERE kind = 1 AND victim_steamid64 IN (`+in+`)`+when+srv+`
|
||||||
|
) x GROUP BY id`, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := map[uint64]Stats{}
|
||||||
|
for rows.Next() {
|
||||||
var id uint64
|
var id uint64
|
||||||
var k, d int
|
var st Stats
|
||||||
if err := krows.Scan(&id, &k, &d); err != nil {
|
if err := rows.Scan(&id, &st.Kills, &st.Deaths, &st.Headshots); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
kd[strconv.FormatUint(id, 10)] = [2]int{k, d}
|
out[id] = st
|
||||||
}
|
}
|
||||||
for i := range out {
|
return out, rows.Err()
|
||||||
out[i].Kills, out[i].Deaths = kd[out[i].SteamID][0], kd[out[i].SteamID][1]
|
|
||||||
}
|
|
||||||
return out, krows.Err()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Record is one player's count in one of the Records categories.
|
// Record is one player's count in one of the Records categories.
|
||||||
|
|
@ -566,14 +598,11 @@ type Events struct {
|
||||||
Headshots int `json:"headshots"`
|
Headshots int `json:"headshots"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stats is kills, deaths and headshot kills between players (pt_events, so 0.2.0 onward).
|
||||||
type Stats struct {
|
type Stats struct {
|
||||||
Kills int `json:"kills"`
|
Kills int `json:"kills"`
|
||||||
Deaths int `json:"deaths"`
|
Deaths int `json:"deaths"`
|
||||||
Assists int `json:"assists"`
|
|
||||||
Headshots int `json:"headshots"`
|
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.
|
// Player is what anyone can see about one player's playtime. Hours are on T or CT unless named.
|
||||||
|
|
@ -586,7 +615,7 @@ type Player struct {
|
||||||
Spec float64 `json:"spec"` // spectating or unassigned
|
Spec float64 `json:"spec"` // spectating or unassigned
|
||||||
Alive float64 `json:"alive"`
|
Alive float64 `json:"alive"`
|
||||||
Dead float64 `json:"dead"`
|
Dead float64 `json:"dead"`
|
||||||
Stats Stats `json:"stats"`
|
Stats *Stats `json:"stats,omitempty"` // nil without pt_events
|
||||||
Events *Events `json:"events,omitempty"` // nil without pt_events
|
Events *Events `json:"events,omitempty"` // nil without pt_events
|
||||||
Days []Day `json:"days"` // last 30 days
|
Days []Day `json:"days"` // last 30 days
|
||||||
Maps []MapTime `json:"maps"`
|
Maps []MapTime `json:"maps"`
|
||||||
|
|
@ -607,11 +636,13 @@ func (s *Store) Player(ctx context.Context, steamid uint64, now time.Time, loc *
|
||||||
h := func(secs int64) float64 { return round1(float64(secs) / 3600) }
|
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)
|
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),
|
if s.events.Load() {
|
||||||
COALESCE(SUM(headshots), 0), COALESCE(SUM(damage), 0), COALESCE(SUM(mvp), 0), COALESCE(SUM(rounds_played), 0)
|
kd, err := s.killsDeaths(ctx, []uint64{steamid}, nil)
|
||||||
FROM pt_sessions WHERE steamid64 = ?`, steamid).
|
if err != nil {
|
||||||
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
|
||||||
return nil, err
|
}
|
||||||
|
st := kd[steamid]
|
||||||
|
p.Stats = &st
|
||||||
}
|
}
|
||||||
|
|
||||||
if s.events.Load() {
|
if s.events.Load() {
|
||||||
|
|
@ -655,22 +686,20 @@ type Session struct {
|
||||||
Reason string `json:"reason,omitempty"`
|
Reason string `json:"reason,omitempty"`
|
||||||
Kills *int `json:"kills,omitempty"`
|
Kills *int `json:"kills,omitempty"`
|
||||||
Deaths *int `json:"deaths,omitempty"`
|
Deaths *int `json:"deaths,omitempty"`
|
||||||
Ping *int `json:"ping,omitempty"`
|
|
||||||
Country string `json:"country,omitempty"`
|
Country string `json:"country,omitempty"`
|
||||||
City string `json:"city,omitempty"`
|
City string `json:"city,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// PlayerStaff is what only staff see: names, sessions, location and ping.
|
// PlayerStaff is what only staff see: names, sessions and location.
|
||||||
type PlayerStaff struct {
|
type PlayerStaff struct {
|
||||||
Names []Nickname `json:"names"`
|
Names []Nickname `json:"names"`
|
||||||
Recent []Session `json:"recent"`
|
Recent []Session `json:"recent"`
|
||||||
Country string `json:"country,omitempty"`
|
Country string `json:"country,omitempty"`
|
||||||
CountryName string `json:"countryName,omitempty"`
|
CountryName string `json:"countryName,omitempty"`
|
||||||
City string `json:"city,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) {
|
func (s *Store) PlayerStaff(ctx context.Context, steamid uint64) (PlayerStaff, error) {
|
||||||
out := PlayerStaff{Names: []Nickname{}, Recent: []Session{}}
|
out := PlayerStaff{Names: []Nickname{}, Recent: []Session{}}
|
||||||
rows, err := s.db.QueryContext(ctx, `SELECT nickname, first_seen, last_seen, times_seen FROM pt_nicknames
|
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)
|
WHERE steamid64 = ? ORDER BY last_seen DESC LIMIT 20`, steamid)
|
||||||
|
|
@ -690,19 +719,28 @@ func (s *Store) PlayerStaff(ctx context.Context, steamid uint64, now time.Time)
|
||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
|
|
||||||
where, args := s.and("server_id", []any{steamid})
|
// Kills and deaths between players from pt_events, for sessions since it began; NULL before.
|
||||||
rows, err = s.db.QueryContext(ctx, `SELECT started_at, ended_at, COALESCE(duration_seconds, TIMESTAMPDIFF(SECOND, started_at, heartbeat_at)),
|
kd := "NULL, NULL"
|
||||||
end_kind, connect_map, COALESCE(disconnect_map, ''), COALESCE(disconnect_reason_name, ''), kills, deaths, ping_avg,
|
if s.events.Load() {
|
||||||
COALESCE(country_iso, ''), COALESCE(city, '')
|
kd = `IF(s.started_at >= (SELECT MIN(at) FROM pt_events),
|
||||||
FROM pt_sessions WHERE steamid64 = ?`+where+` ORDER BY started_at DESC LIMIT 10`, args...)
|
(SELECT COUNT(*) FROM pt_events e WHERE e.session_key = s.session_key AND e.kind = 1 AND NOT e.victim_bot), NULL),
|
||||||
|
IF(s.started_at >= (SELECT MIN(at) FROM pt_events),
|
||||||
|
(SELECT COUNT(*) FROM pt_events e WHERE e.kind = 1 AND e.victim_steamid64 = s.steamid64
|
||||||
|
AND e.at >= s.started_at AND e.at <= COALESCE(s.ended_at, s.heartbeat_at, UTC_TIMESTAMP())), NULL)`
|
||||||
|
}
|
||||||
|
where, args := s.and("s.server_id", []any{steamid})
|
||||||
|
rows, err = s.db.QueryContext(ctx, `SELECT s.started_at, s.ended_at, COALESCE(s.duration_seconds, TIMESTAMPDIFF(SECOND, s.started_at, s.heartbeat_at)),
|
||||||
|
s.end_kind, s.connect_map, COALESCE(s.disconnect_map, ''), COALESCE(s.disconnect_reason_name, ''), `+kd+`,
|
||||||
|
COALESCE(s.country_iso, ''), COALESCE(s.city, '')
|
||||||
|
FROM pt_sessions s WHERE s.steamid64 = ?`+where+` ORDER BY s.started_at DESC LIMIT 10`, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var x Session
|
var x Session
|
||||||
var ended sql.NullTime
|
var ended sql.NullTime
|
||||||
var kills, deaths, ping sql.NullInt64
|
var kills, deaths sql.NullInt64
|
||||||
if err := rows.Scan(&x.Started, &ended, &x.Seconds, &x.EndKind, &x.Map, &x.EndMap, &x.Reason, &kills, &deaths, &ping,
|
if err := rows.Scan(&x.Started, &ended, &x.Seconds, &x.EndKind, &x.Map, &x.EndMap, &x.Reason, &kills, &deaths,
|
||||||
&x.Country, &x.City); err != nil {
|
&x.Country, &x.City); err != nil {
|
||||||
rows.Close()
|
rows.Close()
|
||||||
return out, err
|
return out, err
|
||||||
|
|
@ -710,7 +748,7 @@ func (s *Store) PlayerStaff(ctx context.Context, steamid uint64, now time.Time)
|
||||||
if ended.Valid {
|
if ended.Valid {
|
||||||
x.Ended = &ended.Time
|
x.Ended = &ended.Time
|
||||||
}
|
}
|
||||||
x.Kills, x.Deaths, x.Ping = intPtr(kills), intPtr(deaths), intPtr(ping)
|
x.Kills, x.Deaths = intPtr(kills), intPtr(deaths)
|
||||||
out.Recent = append(out.Recent, x)
|
out.Recent = append(out.Recent, x)
|
||||||
}
|
}
|
||||||
rows.Close()
|
rows.Close()
|
||||||
|
|
@ -724,15 +762,6 @@ func (s *Store) PlayerStaff(ctx context.Context, steamid uint64, now time.Time)
|
||||||
if err != nil && err != sql.ErrNoRows {
|
if err != nil && err != sql.ErrNoRows {
|
||||||
return out, err
|
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
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -116,18 +116,20 @@ func TestPlaytimeDB(t *testing.T) {
|
||||||
|
|
||||||
// Events: Alpha plants twice (once 10 days ago), defuses once and knifes a player and a bot;
|
// 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.
|
// Bravo gets a Zeus kill and a headshot.
|
||||||
event := func(id uint64, kind int, at time.Time, weapon any, hs any, bot any) {
|
// 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()
|
t.Helper()
|
||||||
exec(`INSERT INTO pt_events (event_key, server_id, steamid64, kind, at, map, team, weapon, headshot, victim_bot)
|
exec(`INSERT INTO pt_events (event_key, server_id, session_key, steamid64, kind, at, map, team, weapon, headshot,
|
||||||
VALUES (REPLACE(UUID(), '-', ''), 1, ?, ?, ?, 'de_mirage', 2, ?, ?, ?)`, id, kind, at, weapon, hs, bot)
|
victim_steamid64, victim_bot)
|
||||||
|
VALUES (REPLACE(UUID(), '-', ''), 1, NULLIF(?, ''), ?, ?, ?, 'de_mirage', 2, ?, ?, ?, ?)`, sess, id, kind, at, weapon, hs, victim, bot)
|
||||||
}
|
}
|
||||||
event(a, EventPlanted, ago(2*time.Hour), nil, nil, nil)
|
event(a, "", EventPlanted, ago(10*24*time.Hour), nil, nil, nil, nil)
|
||||||
event(a, EventPlanted, ago(10*24*time.Hour), nil, nil, nil)
|
event(a, "a1", EventPlanted, ago(2*time.Hour), nil, nil, nil, nil)
|
||||||
event(a, EventDefused, ago(2*time.Hour), nil, nil, nil)
|
event(a, "a1", EventDefused, ago(2*time.Hour), nil, nil, nil, nil)
|
||||||
event(a, EventKill, ago(2*time.Hour), "knife_karambit", 0, 0)
|
event(a, "a1", EventKill, ago(2*time.Hour), "knife_karambit", 0, b, 0)
|
||||||
event(a, EventKill, ago(2*time.Hour), "knife", 1, 1) // a bot: doesn't count, not even as a headshot
|
event(a, "a1", EventKill, ago(2*time.Hour), "knife", 1, nil, 1) // a bot: doesn't count, not even as a headshot
|
||||||
event(b, EventKill, ago(20*time.Minute), "taser", 0, 0)
|
event(b, "b2", EventKill, ago(20*time.Minute), "taser", 0, a, 0)
|
||||||
event(b, EventKill, ago(20*time.Minute), "ak47", 1, 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() {
|
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())
|
t.Fatalf("State with data: %v, %v, %v (events %v)", ok, data, err, st.HasEvents())
|
||||||
|
|
@ -194,13 +196,13 @@ func TestPlaytimeDB(t *testing.T) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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 {
|
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)
|
t.Fatalf("leaders this week %+v", leaders)
|
||||||
}
|
}
|
||||||
if leaders, err = st.Leaders(ctx, nil, 10); err != nil {
|
if leaders, err = st.Leaders(ctx, nil, 10); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if len(leaders) != 2 || leaders[0].Name != "Bravo" || leaders[0].Kills != 3 {
|
if len(leaders) != 2 || leaders[0].Name != "Bravo" || leaders[0].Kills != 2 || leaders[0].Deaths != 1 {
|
||||||
t.Fatalf("leaders all time %+v", leaders)
|
t.Fatalf("leaders all time %+v", leaders)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -212,19 +214,23 @@ func TestPlaytimeDB(t *testing.T) {
|
||||||
for _, d := range p.Days {
|
for _, d := range p.Days {
|
||||||
days += d.Hours
|
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 {
|
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)
|
t.Fatalf("player %+v, %.1f h in days", p, days)
|
||||||
}
|
}
|
||||||
if p, err := st.Player(ctx, 76561198000009999, now, london); err != nil || p != nil {
|
if p, err := st.Player(ctx, 76561198000009999, now, london); err != nil || p != nil {
|
||||||
t.Fatalf("unknown player: %v, %v", p, err)
|
t.Fatalf("unknown player: %v, %v", p, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
ps, err := st.PlayerStaff(ctx, a, now)
|
ps, err := st.PlayerStaff(ctx, a)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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 ||
|
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 {
|
ps.Recent[0].Reason != "NETWORK_DISCONNECT_DISCONNECT_BY_USER" || ps.Country != "GB" || ps.City != "London" {
|
||||||
t.Fatalf("staff %+v", ps)
|
t.Fatalf("staff %+v", ps)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -338,15 +338,15 @@ function ptDrawer(pt, online) {
|
||||||
<h3>Last 30 days</h3>
|
<h3>Last 30 days</h3>
|
||||||
<div class="pt-spark" role="img" aria-label="Hours played each day">${p.days.map((d) => `<i class="${d.hours ? "" : "zero"}" style="height:${d.hours ? Math.max(6, (d.hours / maxDay) * 100) : 4}%" data-tip="${esc(`${ptDay(d.date)} · ${d.hours ? ptH(d.hours) : "didn't play"}`)}"></i>`).join("")}</div>
|
<div class="pt-spark" role="img" aria-label="Hours played each day">${p.days.map((d) => `<i class="${d.hours ? "" : "zero"}" style="height:${d.hours ? Math.max(6, (d.hours / maxDay) * 100) : 4}%" data-tip="${esc(`${ptDay(d.date)} · ${d.hours ? ptH(d.hours) : "didn't play"}`)}"></i>`).join("")}</div>
|
||||||
</section>
|
</section>
|
||||||
${st.kills + st.deaths > 0 ? `<section>
|
${st && st.kills + st.deaths > 0 ? `<section>
|
||||||
<h3>Stats</h3>
|
<h3>Stats</h3>
|
||||||
<div class="pt-stats">
|
<div class="pt-stats">
|
||||||
<div><b>${ptKD(st.kills, st.deaths)}</b><small>K/D</small></div>
|
<div><b>${ptKD(st.kills, st.deaths)}</b><small>K/D</small></div>
|
||||||
<div><b>${ptPct(st.headshots, st.kills)}%</b><small>Headshot kills</small></div>
|
<div><b>${ptPct(st.headshots, st.kills)}%</b><small>Headshot kills</small></div>
|
||||||
<div><b>${st.rounds ? Math.round(st.damage / st.rounds) : "–"}</b><small>Damage per round</small></div>
|
<div><b>${st.kills.toLocaleString()}</b><small>Kills</small></div>
|
||||||
<div><b>${st.mvps}</b><small>MVPs</small></div>
|
<div><b>${st.deaths.toLocaleString()}</b><small>Deaths</small></div>
|
||||||
</div>
|
</div>
|
||||||
<p class="muted" style="margin:10px 0 0;font-size:14px">${st.kills.toLocaleString()} kills, ${st.deaths.toLocaleString()} deaths and ${st.assists.toLocaleString()} assists over ${st.rounds.toLocaleString()} rounds.</p>
|
<p class="muted" style="margin:10px 0 0;font-size:14px">Against other players only: bots don't count.</p>
|
||||||
</section>` : ""}
|
</section>` : ""}
|
||||||
${p.events && Object.values(p.events).some((n) => n > 0) ? `<section>
|
${p.events && Object.values(p.events).some((n) => n > 0) ? `<section>
|
||||||
<h3>Records</h3>
|
<h3>Records</h3>
|
||||||
|
|
@ -356,7 +356,6 @@ function ptDrawer(pt, online) {
|
||||||
<div><b>${p.events.knifeKills.toLocaleString()}</b><small>Knife kills</small></div>
|
<div><b>${p.events.knifeKills.toLocaleString()}</b><small>Knife kills</small></div>
|
||||||
<div><b>${p.events.zeusKills.toLocaleString()}</b><small>Zeus kills</small></div>
|
<div><b>${p.events.zeusKills.toLocaleString()}</b><small>Zeus kills</small></div>
|
||||||
</div>
|
</div>
|
||||||
<p class="muted" style="margin:10px 0 0;font-size:14px">${p.events.headshots.toLocaleString()} headshot kills. Counted as they happen, not in warmup or on bots.</p>
|
|
||||||
</section>` : ""}
|
</section>` : ""}
|
||||||
${p.maps.length ? `<section>
|
${p.maps.length ? `<section>
|
||||||
<h3>Favourite maps</h3>
|
<h3>Favourite maps</h3>
|
||||||
|
|
@ -381,17 +380,14 @@ function ptDrawer(pt, online) {
|
||||||
<h3>Recent sessions</h3>
|
<h3>Recent sessions</h3>
|
||||||
<ul class="pt-sessions">${s.recent.map((x) => `<li>
|
<ul class="pt-sessions">${s.recent.map((x) => `<li>
|
||||||
<div class="top"><b>${fmtDate(x.started)}</b><span>${ptDur(x.seconds)}</span></div>
|
<div class="top"><b>${fmtDate(x.started)}</b><span>${ptDur(x.seconds)}</span></div>
|
||||||
<div class="muted">${esc(x.map)}${x.endMap && x.endMap !== x.map ? ` → ${esc(x.endMap)}` : ""}${x.kills != null ? ` · ${x.kills}–${x.deaths}` : ""}${x.ping != null ? ` · ${x.ping} ms` : ""}</div>
|
<div class="muted">${esc(x.map)}${x.endMap && x.endMap !== x.map ? ` → ${esc(x.endMap)}` : ""}${x.kills != null ? ` · ${x.kills}–${x.deaths}` : ""}</div>
|
||||||
<div>${reason(x)}</div>
|
<div>${reason(x)}</div>
|
||||||
</li>`).join("")}</ul>
|
</li>`).join("")}</ul>
|
||||||
</section>` : ""}
|
</section>` : ""}
|
||||||
${s.country || s.ping != null ? `<section>
|
${s.country ? `<section>
|
||||||
<h3>Connection</h3>
|
<h3>Location</h3>
|
||||||
<dl class="kv">
|
<p style="margin:0">${s.city ? `${esc(s.city)}, ` : ""}${esc(s.countryName || s.country)}</p>
|
||||||
${s.country ? `<dt>Location</dt><dd>${s.city ? `${esc(s.city)}, ` : ""}${esc(s.countryName || s.country)}</dd>` : ""}
|
</section>${PT_DBIP}` : ""}`;
|
||||||
${s.ping != null ? `<dt>Average ping</dt><dd>${s.ping} ms <span class="muted">last 30 days</span></dd>` : ""}
|
|
||||||
</dl>
|
|
||||||
</section>${s.country ? PT_DBIP : ""}` : ""}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- Events ---------- */
|
/* ---------- Events ---------- */
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,13 @@ html.drawer-open { overflow: hidden; }
|
||||||
html.drawer-open body { overflow: hidden; }
|
html.drawer-open body { overflow: hidden; }
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background: var(--bg);
|
/* The server header's 24px grid, across the whole page (fainter: the page is darker than a
|
||||||
|
panel), fixed so content scrolls over it. Panels are solid, so it shows between them. */
|
||||||
|
background:
|
||||||
|
repeating-linear-gradient(0deg, transparent 0 23px, rgba(255, 255, 255, 0.028) 23px 24px),
|
||||||
|
repeating-linear-gradient(90deg, transparent 0 23px, rgba(255, 255, 255, 0.028) 23px 24px),
|
||||||
|
var(--bg);
|
||||||
|
background-attachment: fixed;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font: 400 15px/1.5 var(--body);
|
font: 400 15px/1.5 var(--body);
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
|
|
|
||||||
|
|
@ -359,7 +359,7 @@ func (s *Server) playerPlaytime(r *http.Request, steamid string, staff bool) map
|
||||||
}
|
}
|
||||||
out := map[string]any{"player": p}
|
out := map[string]any{"player": p}
|
||||||
if staff {
|
if staff {
|
||||||
extra, err := s.pt.PlayerStaff(ctx, n, now)
|
extra, err := s.pt.PlayerStaff(ctx, n)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Warn("staff playtime for a player", "steamid", steamid, "err", err)
|
slog.Warn("staff playtime for a player", "steamid", steamid, "err", err)
|
||||||
return out
|
return out
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue