A Running, ready server always reported 0/0 players: markRunningReady
never wrote Status.Players, and markStopped only cleared it. The panel
therefore showed an empty tally for live servers.
Extend the readiness probe to also sample the player count. Prober.Probe
now returns a PlayerCount{Online, Max}: RconProber still gates readiness
on Dial+auth, then runs a best-effort `list` and parses the vanilla
reply ("There are N of a max of M players online"). A failed or
unparseable tally is swallowed (0/0) so it never blocks readiness. The
reconciler threads the count into markRunningReady, which writes
Status.Players; markStopped still resets it to zero.
94 lines
3.3 KiB
Go
94 lines
3.3 KiB
Go
package operator
|
|
|
|
import (
|
|
"context"
|
|
"regexp"
|
|
"strconv"
|
|
"time"
|
|
|
|
"felis.lolicon.best/internal/rcon"
|
|
)
|
|
|
|
// PlayerCount is a server's online/max player tally as read from RCON `list`.
|
|
// Both fields are zero when the count could not be read; that is not an error,
|
|
// only the absence of a fresh sample (see Prober).
|
|
type PlayerCount struct {
|
|
Online int32
|
|
Max int32
|
|
}
|
|
|
|
// Prober reports whether a server's RCON endpoint is reachable and accepts the
|
|
// password, and best-effort returns its current player tally. A nil error is the
|
|
// loader-agnostic readiness gate (spec §5); the PlayerCount is advisory and is
|
|
// zero (with a nil error) whenever the tally could not be sampled. It is an
|
|
// interface so the reconciler can be tested without a live server.
|
|
type Prober interface {
|
|
Probe(ctx context.Context, addr, password string) (PlayerCount, error)
|
|
}
|
|
|
|
// RconProber is the production Prober: a successful Dial (TCP connect + auth)
|
|
// is the readiness gate; on that same connection it then runs `list` to sample
|
|
// the player tally before closing.
|
|
type RconProber struct {
|
|
// Timeout bounds a single probe. Defaults to 5s.
|
|
Timeout time.Duration
|
|
}
|
|
|
|
// Probe dials addr and authenticates with password, honoring the smaller of the
|
|
// configured timeout and any deadline already on ctx. Auth success gates
|
|
// readiness; the player tally is then read with `list` on a best-effort basis —
|
|
// a failed or unparseable `list` yields a zero PlayerCount, never a probe error,
|
|
// so a transient count-read hiccup can never flap a healthy server out of Ready.
|
|
func (p RconProber) Probe(ctx context.Context, addr, password string) (PlayerCount, error) {
|
|
timeout := p.Timeout
|
|
if timeout <= 0 {
|
|
timeout = 5 * time.Second
|
|
}
|
|
if dl, ok := ctx.Deadline(); ok {
|
|
if remaining := time.Until(dl); remaining > 0 && remaining < timeout {
|
|
timeout = remaining
|
|
}
|
|
}
|
|
conn, err := rcon.Dial(addr, password, timeout)
|
|
if err != nil {
|
|
return PlayerCount{}, err
|
|
}
|
|
defer conn.Close()
|
|
|
|
// Readiness is already established by the successful Dial+auth above. Reading
|
|
// the tally must not jeopardize that verdict, so its errors are swallowed.
|
|
if err := conn.SetDeadline(time.Now().Add(timeout)); err != nil {
|
|
return PlayerCount{}, nil
|
|
}
|
|
reply, err := conn.Execute("list")
|
|
if err != nil {
|
|
return PlayerCount{}, nil
|
|
}
|
|
pc, _ := parseListReply(reply)
|
|
return pc, nil
|
|
}
|
|
|
|
// listReplyPattern matches the vanilla/Paper `list` response, e.g.
|
|
// "There are 3 of a max of 20 players online: alice, bob, carol". The search is
|
|
// unanchored so leading color codes or trailing player names do not defeat it.
|
|
var listReplyPattern = regexp.MustCompile(`There are (\d+) of a max of (\d+) players online`)
|
|
|
|
// parseListReply extracts the online/max tally from a `list` reply. ok is false
|
|
// (and the PlayerCount zero) when the reply does not match the known format, so
|
|
// callers can distinguish "no sample" from a genuine "0 of N".
|
|
func parseListReply(reply string) (PlayerCount, bool) {
|
|
m := listReplyPattern.FindStringSubmatch(reply)
|
|
if m == nil {
|
|
return PlayerCount{}, false
|
|
}
|
|
online, err := strconv.ParseInt(m[1], 10, 32)
|
|
if err != nil {
|
|
return PlayerCount{}, false
|
|
}
|
|
max, err := strconv.ParseInt(m[2], 10, 32)
|
|
if err != nil {
|
|
return PlayerCount{}, false
|
|
}
|
|
return PlayerCount{Online: int32(online), Max: int32(max)}, true
|
|
}
|