fix(operator): populate Status.Players from an RCON list probe

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.
This commit is contained in:
flyemoji committed 2026-06-30 20:04:46 +09:00
1 parent 6c3999a067
commit 98739044a5
4 files changed
+149 -14

No files matched your search

+60 -8
View File
@@ -2,28 +2,44 @@ 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. A nil error is the loader-agnostic readiness gate (spec §5). It is
// an interface so the reconciler can be tested without a live server.
// 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) error
Probe(ctx context.Context, addr, password string) (PlayerCount, error)
}
// RconProber is the production Prober: a successful Dial (TCP connect + auth)
// is sufficient; the connection is closed immediately.
// 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.
func (p RconProber) Probe(ctx context.Context, addr, password string) error {
// 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
@@ -35,7 +51,43 @@ func (p RconProber) Probe(ctx context.Context, addr, password string) error {
}
conn, err := rcon.Dial(addr, password, timeout)
if err != nil {
return err
return PlayerCount{}, err
}
return conn.Close()
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
}