// Package live polls the game server through the WebPanelBridge plugin and caches the result, so // page views never wait on RCON. package live import ( "context" "encoding/json" "errors" "log/slog" "strings" "sync" "time" "git.zio.sh/cs2/simpleadmin-web/internal/rcon" ) const ( TeamSpectator = 1 TeamT = 2 TeamCT = 3 ) type Player struct { UserID int `json:"userid"` Name string `json:"name"` SteamID string `json:"steamid"` Team int `json:"team"` Kills int `json:"kills"` Deaths int `json:"deaths"` Ping int `json:"ping"` Bot bool `json:"bot"` } type Status struct { Online bool `json:"online"` Error string `json:"-"` Hostname string `json:"hostname"` Map string `json:"map"` MaxPlayers int `json:"maxPlayers"` ScoreT int `json:"scoreT"` ScoreCT int `json:"scoreCt"` Warmup bool `json:"warmup"` Players []Player `json:"players"` Updated time.Time `json:"updated"` } // Humans returns the connected players that aren't bots. func (s Status) Humans() []Player { out := make([]Player, 0, len(s.Players)) for _, p := range s.Players { if !p.Bot { out = append(out, p) } } return out } // Find returns the online player with this SteamID64. func (s Status) Find(steamid string) (Player, bool) { for _, p := range s.Players { if !p.Bot && p.SteamID == steamid { return p, true } } return Player{}, false } var errNoBridge = errors.New("WebPanelBridge didn't answer; is the plugin installed?") // Parse reads css_webpanel_status output. func Parse(out string) (Status, error) { var st Status gotServer, gotEnd := false, false for line := range strings.SplitSeq(out, "\n") { line = strings.TrimSpace(line) rest, ok := strings.CutPrefix(line, "wpb ") if !ok { continue } if rest == "end" { gotEnd = true continue } if !gotServer { var srv struct { V int `json:"v"` Hostname string `json:"hostname"` Map string `json:"map"` MaxPlayers int `json:"maxPlayers"` ScoreT int `json:"scoreT"` ScoreCt int `json:"scoreCt"` Warmup bool `json:"warmup"` } if err := json.Unmarshal([]byte(rest), &srv); err != nil || srv.V != 1 { return st, errors.New("unrecognised WebPanelBridge output") } st.Hostname, st.Map, st.MaxPlayers = srv.Hostname, srv.Map, srv.MaxPlayers st.ScoreT, st.ScoreCT, st.Warmup = srv.ScoreT, srv.ScoreCt, srv.Warmup gotServer = true continue } var p Player if err := json.Unmarshal([]byte(rest), &p); err == nil { st.Players = append(st.Players, p) } } if !gotServer { return st, errNoBridge } if !gotEnd { return st, errors.New("WebPanelBridge output was cut off") } st.Online = true return st, nil } type Poller struct { rc *rcon.Client interval time.Duration mu sync.RWMutex current Status } func NewPoller(rc *rcon.Client, interval time.Duration) *Poller { return &Poller{rc: rc, interval: interval} } // Run polls until ctx is done. func (p *Poller) Run(ctx context.Context) { t := time.NewTicker(p.interval) defer t.Stop() p.Refresh() for { select { case <-ctx.Done(): return case <-t.C: p.Refresh() } } } // Refresh polls now. Actions call it so the next page load shows their effect. func (p *Poller) Refresh() Status { out, err := p.rc.Exec("css_webpanel_status") var st Status if err == nil { st, err = Parse(out) } st.Updated = time.Now() if err != nil { st = Status{Error: err.Error(), Updated: st.Updated} // Log each new kind of failure once, so a wrong password or missing plugin shows up in the // log without repeating every few seconds. ErrDown only repeats the previous failure. p.mu.RLock() changed := p.current.Online || p.current.Updated.IsZero() || p.current.Error != st.Error p.mu.RUnlock() if changed && !errors.Is(err, rcon.ErrDown) { slog.Warn("game server status unavailable", "err", err) } } p.mu.Lock() if st.Online && !p.current.Online && !p.current.Updated.IsZero() { slog.Info("game server status available again") } // While backing off, keep the underlying error rather than "retrying shortly". if errors.Is(err, rcon.ErrDown) && p.current.Error != "" { st.Error = p.current.Error } p.current = st p.mu.Unlock() return st } func (p *Poller) Current() Status { p.mu.RLock() defer p.mu.RUnlock() return p.current }