fix(operator): 读不出玩家数时暂停空闲自动停服,支持 1.12/Bukkit/EssentialsX 与颜色码

This commit is contained in:
Lemon-miaow committed 2026-09-24 16:06:55 +08:00
1 parent 857c73a66a
commit bd909d5858
6 files changed
+213 -57

No files matched your search

+27 -14
View File
@@ -716,24 +716,37 @@ Check it first.
kubectl get minecraftserver <name> -o jsonpath='{.spec.rcon.enabled}' kubectl get minecraftserver <name> -o jsonpath='{.spec.rcon.enabled}'
``` ```
The player tally is a by-product of the RCON readiness probe — `prober.go:63` The player tally is a by-product of the RCON readiness probe: `prober.go` runs
runs `list` on the same connection that just authenticated, and `parseListReply` `list` on the same connection that just authenticated, and `parseListReply`
extracts the tally from `There are (\d+) of a max of (\d+) players online`. With reads the tally from the vanilla/Paper/Fabric/Forge reply (`There are 3 of a
RCON disabled the probe never runs, `players` keeps its zero value, and max of 20 players online`), the 1.12/Bukkit reply (`There are 3/20 players
`markRunningReady` (`reconciler.go:413`) writes that zero into online`) or the EssentialsX reply (`There are 3 out of maximum 20 players
`status.players.online`. So a permanent 0 means "never sampled", not "nobody online`, vanished players included), with `§` color codes stripped. With RCON
online". disabled the probe never runs and no tally is ever read, so a permanent 0 means
"never sampled", not "nobody online".
Idle auto-stop (`reconciler.go:175`) reads that same tally, which is why it A tally that could not be read counts as **unknown**, never as zero. Idle
carries the RCON condition explicitly: auto-stop only acts on a count it actually read:
```go ```go
if server.Spec.Rcon.Enabled && server.Spec.Idle.AutoStopEnabled && server.Spec.Idle.EmptySecondsBeforeStop > 0 { if players.Known && server.Spec.Idle.AutoStopEnabled && server.Spec.Idle.EmptySecondsBeforeStop > 0 {
``` ```
The comment above it says why: with RCON off the zero tally "would read as An unknown tally leaves `status.players` at the last real count, neither starts
'empty' and use to stop a server full of people". So the guard is deliberate — nor clears the empty countdown, and sets the `PlayersCounted` condition to
enabling `spec.idle.*` without RCON is a no-op by design, not a missing feature. `False` with reason `ListUnreadable`. So enabling `spec.idle.*` without RCON is
a no-op by design, and a server whose `list` reply is in a format Felis does
not know (a plugin that rewrites `/list`, a translated reply) never idles out:
```sh
kubectl get minecraftserver <name> -o jsonpath='{.status.conditions[?(@.type=="PlayersCounted")]}'
# Reason ListUnreadable: run `list` in the server's panel console to see
# what the server actually answers.
```
Fix it by restoring a supported `/list` (for example, drop the plugin's
override or its translation of that one message). The server keeps running
either way; only the idle stop waits.
Both fields set and still nothing happens? Then the probe is failing rather than Both fields set and still nothing happens? Then the probe is failing rather than
disabled: the server would be stuck in `Starting` with `RconNotReachable` disabled: the server would be stuck in `Starting` with `RconNotReachable`
@@ -1286,7 +1299,7 @@ for 10 seconds (the Free plan's limits).
| Build push 400 / SA denied / egress hang / Failed / executor ImagePullBackOff | §8, §8e | | Build push 400 / SA denied / egress hang / Failed / executor ImagePullBackOff | §8, §8e |
| Registry push/pull unreachable | §9 | | Registry push/pull unreachable | §9 |
| World deleted unexpectedly / backup skipped | §10 | | World deleted unexpectedly / backup skipped | §10 |
| Idle auto-stop not firing; player count 0 | §11 | | Idle auto-stop not firing; player count 0; `PlayersCounted=False` | §11 |
| A config field seems ignored | §12 | | A config field seems ignored | §12 |
| PVC left behind after delete | §13 | | PVC left behind after delete | §13 |
| Node out of disk; pods evicted / ImagePullBackOff | §13b | | Node out of disk; pods evicted / ImagePullBackOff | §13b |
@@ -72,6 +72,9 @@ const (
ConditionReady = "Ready" ConditionReady = "Ready"
ConditionRconReached = "RconReached" ConditionRconReached = "RconReached"
ConditionProvisioned = "Provisioned" ConditionProvisioned = "Provisioned"
// ConditionPlayersCounted is False while the RCON `list` reply cannot be
// read; idle auto-stop waits for a real count (spec §8).
ConditionPlayersCounted = "PlayersCounted"
) )
// +kubebuilder:object:root=true // +kubebuilder:object:root=true
+55 -23
View File
@@ -10,17 +10,20 @@ import (
) )
// PlayerCount is a server's online/max player tally as read from RCON `list`. // 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, // Known is false when the count could not be read (the command failed, or its
// only the absence of a fresh sample (see Prober). // reply matched no format below); that is not a probe error, only the absence
// of a fresh sample. The zero value is "unknown", so a caller that forgets to
// check can never mistake a failed read for an empty server.
type PlayerCount struct { type PlayerCount struct {
Online int32 Online int32
Max int32 Max int32
Known bool
} }
// Prober reports whether a server's RCON endpoint is reachable and accepts the // 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 // 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 // loader-agnostic readiness gate (spec §5); the PlayerCount is advisory and has
// zero (with a nil error) whenever the tally could not be sampled. It is an // Known=false (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. // interface so the reconciler can be tested without a live server.
type Prober interface { type Prober interface {
Probe(ctx context.Context, addr, password string) (PlayerCount, error) Probe(ctx context.Context, addr, password string) (PlayerCount, error)
@@ -37,7 +40,7 @@ type RconProber struct {
// Probe dials addr and authenticates with password, honoring the smaller of the // Probe dials addr and authenticates with password, honoring the smaller of the
// configured timeout and any deadline already on ctx. Auth success gates // 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 — // 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, // a failed or unparseable `list` yields an unknown PlayerCount, never a probe error,
// so a transient count-read hiccup can never flap a healthy server out of Ready. // 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) { func (p RconProber) Probe(ctx context.Context, addr, password string) (PlayerCount, error) {
timeout := p.Timeout timeout := p.Timeout
@@ -68,26 +71,55 @@ func (p RconProber) Probe(ctx context.Context, addr, password string) (PlayerCou
return pc, nil return pc, nil
} }
// listReplyPattern matches the vanilla/Paper `list` response, e.g. // listReplyPatterns match the `list` replies of the loaders Felis runs, tried in
// "There are 3 of a max of 20 players online: alice, bob, carol". The search is // order against the reply with § color codes stripped:
// 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`) // - vanilla 1.13+ / Paper / Fabric / Forge:
// "There are 3 of a max of 20 players online: alice, bob, carol"
// - vanilla 1.12 and older, Bukkit's own list:
// "There are 3/20 players online:"
// - EssentialsX (its /list replaces the vanilla one, RCON included); with
// vanished players it prints visible/hidden, and both count as online:
// "There are 3 out of maximum 20 players online."
// "There are 3/1 out of maximum 20 players online."
//
// Each has groups (online, hidden, max); hidden is empty where the format has
// none. The search is unanchored so trailing player names do not defeat it.
var listReplyPatterns = []*regexp.Regexp{
regexp.MustCompile(`There are (\d+)() of a max(?:imum)? of (\d+) players online`),
regexp.MustCompile(`There are (\d+)(?:/(\d+))? out of (?:a )?maximum (?:of )?(\d+) players online`),
regexp.MustCompile(`There are (\d+)()/(\d+) players online`),
}
// colorCode matches a legacy § formatting code (color, bold, reset, ...).
var colorCode = regexp.MustCompile(`(?i)§[0-9a-fk-orx]`)
// parseListReply extracts the online/max tally from a `list` reply. ok is false // 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 // (and the PlayerCount unknown) when the reply matches none of the known
// callers can distinguish "no sample" from a genuine "0 of N". // formats, so callers can distinguish "no sample" from a genuine "0 of N".
func parseListReply(reply string) (PlayerCount, bool) { func parseListReply(reply string) (PlayerCount, bool) {
m := listReplyPattern.FindStringSubmatch(reply) plain := colorCode.ReplaceAllString(reply, "")
if m == nil { for _, re := range listReplyPatterns {
return PlayerCount{}, false m := re.FindStringSubmatch(plain)
if m == nil {
continue
}
online, err := strconv.ParseInt(m[1], 10, 32)
if err != nil {
return PlayerCount{}, false
}
if m[2] != "" {
hidden, err := strconv.ParseInt(m[2], 10, 32)
if err != nil {
return PlayerCount{}, false
}
online += hidden
}
max, err := strconv.ParseInt(m[3], 10, 32)
if err != nil {
return PlayerCount{}, false
}
return PlayerCount{Online: int32(online), Max: int32(max), Known: true}, true
} }
online, err := strconv.ParseInt(m[1], 10, 32) return PlayerCount{}, false
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
} }
+33 -3
View File
@@ -12,21 +12,51 @@ func TestParseListReply(t *testing.T) {
{ {
name: "empty server", name: "empty server",
reply: "There are 0 of a max of 20 players online:", reply: "There are 0 of a max of 20 players online:",
want: PlayerCount{Online: 0, Max: 20}, want: PlayerCount{Online: 0, Max: 20, Known: true},
wantOK: true, wantOK: true,
}, },
{ {
name: "with player names", name: "with player names",
reply: "There are 3 of a max of 20 players online: alice, bob, carol", reply: "There are 3 of a max of 20 players online: alice, bob, carol",
want: PlayerCount{Online: 3, Max: 20}, want: PlayerCount{Online: 3, Max: 20, Known: true},
wantOK: true, wantOK: true,
}, },
{ {
name: "full server", name: "full server",
reply: "There are 20 of a max of 20 players online: ...", reply: "There are 20 of a max of 20 players online: ...",
want: PlayerCount{Online: 20, Max: 20}, want: PlayerCount{Online: 20, Max: 20, Known: true},
wantOK: true, wantOK: true,
}, },
{
name: "color codes around the numbers",
reply: "§6There are §c2§6 of a max of §c50§6 players online:§r alice, bob",
want: PlayerCount{Online: 2, Max: 50, Known: true},
wantOK: true,
},
{
name: "vanilla 1.12 and Bukkit slash form",
reply: "There are 4/32 players online:\nalice, bob, carol, dave",
want: PlayerCount{Online: 4, Max: 32, Known: true},
wantOK: true,
},
{
name: "EssentialsX",
reply: "§6There are §c0§6 out of maximum §c20§6 players online.",
want: PlayerCount{Online: 0, Max: 20, Known: true},
wantOK: true,
},
{
name: "EssentialsX with vanished players counts them online",
reply: "§6There are §c1§6/§c2§6 out of maximum §c20§6 players online.",
want: PlayerCount{Online: 3, Max: 20, Known: true},
wantOK: true,
},
{
name: "translated reply yields no sample",
reply: "当前有 0 个玩家在线,最大在线人数为 20 个玩家。",
want: PlayerCount{},
wantOK: false,
},
{ {
name: "unrecognized reply yields no sample", name: "unrecognized reply yields no sample",
reply: "Unknown command. Try /help for a list of commands.", reply: "Unknown command. Try /help for a list of commands.",
+20 -6
View File
@@ -217,16 +217,27 @@ func (r *Reconciler) reconcileRunning(ctx context.Context, server *v1alpha1.Mine
return ctrl.Result{RequeueAfter: requeueStarting}, nil return ctrl.Result{RequeueAfter: requeueStarting}, nil
} }
players = pc players = pc
// A tally that could not be read pauses idle auto-stop (below) instead of
// counting as an empty server; the condition says so, so a server that
// never stops idle shows why.
if pc.Known {
r.setCondition(server, v1alpha1.ConditionPlayersCounted, metav1.ConditionTrue, "Counted", "RCON list reply read")
} else {
r.setCondition(server, v1alpha1.ConditionPlayersCounted, metav1.ConditionFalse, "ListUnreadable",
"RCON list failed or its reply matched no known format; idle auto-stop is paused until the player count can be read")
}
} }
// Idle auto-stop (spec §8): when enabled, the server is Running, and the // Idle auto-stop (spec §8): when enabled, the server is Running, and the
// player tally is zero, track the empty duration and auto-stop when the // player tally is zero, track the empty duration and auto-stop when the
// configured timeout expires. The existing RCON probe already supplies // configured timeout expires. The existing RCON probe already supplies
// the player count — no extra network cost. // the player count — no extra network cost.
// Rcon.Enabled is part of the condition because `players` is only a real tally // players.Known gates the whole branch: an unread tally (RCON off, `list`
// when the probe above ran: with RCON off it keeps its zero value, which this // failed, or a reply format the parser does not know) must never read as
// branch would read as "empty" and use to stop a server full of people. // "empty" and stop a server full of people. It neither stamps nor clears
if server.Spec.Rcon.Enabled && server.Spec.Idle.AutoStopEnabled && server.Spec.Idle.EmptySecondsBeforeStop > 0 { // EmptySince, so a flaky read does not restart the countdown either; the
// stop itself only ever follows a sample that really said zero.
if players.Known && server.Spec.Idle.AutoStopEnabled && server.Spec.Idle.EmptySecondsBeforeStop > 0 {
if players.Online == 0 { if players.Online == 0 {
if server.Status.EmptySince == nil { if server.Status.EmptySince == nil {
t := r.now() t := r.now()
@@ -525,8 +536,11 @@ func (r *Reconciler) markRunningReady(server *v1alpha1.MinecraftServer, players
server.Status.Ready = true server.Status.Ready = true
server.Status.ObservedGeneration = server.Generation server.Status.ObservedGeneration = server.Generation
// Refresh the player tally sampled by this reconcile's RCON probe so the panel // Refresh the player tally sampled by this reconcile's RCON probe so the panel
// reports live occupancy instead of the 0/0 markStopped leaves behind. // reports live occupancy instead of the 0/0 markStopped leaves behind. An
server.Status.Players = v1alpha1.PlayersStatus{Online: players.Online, Max: players.Max} // unread tally keeps the last one shown.
if players.Known {
server.Status.Players = v1alpha1.PlayersStatus{Online: players.Online, Max: players.Max}
}
if server.Status.ReadySignalAt == nil { if server.Status.ReadySignalAt == nil {
t := r.now() t := r.now()
server.Status.ReadySignalAt = &t server.Status.ReadySignalAt = &t
+75 -11
View File
@@ -14,6 +14,7 @@ import (
appsv1 "k8s.io/api/apps/v1" appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1" corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors" apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types" "k8s.io/apimachinery/pkg/types"
@@ -254,7 +255,7 @@ func TestReconcileRunning_RconProbeGatesReadiness(t *testing.T) {
} }
func TestReconcileRunning_PopulatesPlayerTally(t *testing.T) { func TestReconcileRunning_PopulatesPlayerTally(t *testing.T) {
r, c := newReconciler(t, fakeProber{players: operator.PlayerCount{Online: 3, Max: 20}}, runningServer(), rconSecret()) r, c := newReconciler(t, fakeProber{players: operator.PlayerCount{Online: 3, Max: 20, Known: true}}, runningServer(), rconSecret())
reconcile(t, r, "survival") // creates workload, Starting reconcile(t, r, "survival") // creates workload, Starting
markPodReady(t, c, "survival") markPodReady(t, c, "survival")
@@ -331,7 +332,7 @@ func TestReconcileStopped_ScalesRunningWorkloadDown(t *testing.T) {
// TestIdleAutoStop_EmptyServerGetsTimestamp verifies that the first Running // TestIdleAutoStop_EmptyServerGetsTimestamp verifies that the first Running
// reconcile with zero players stamps EmptySince and keeps the server Running. // reconcile with zero players stamps EmptySince and keeps the server Running.
func TestIdleAutoStop_EmptyServerGetsTimestamp(t *testing.T) { func TestIdleAutoStop_EmptyServerGetsTimestamp(t *testing.T) {
r, c := newReconciler(t, fakeProber{players: operator.PlayerCount{Online: 0, Max: 20}}, runningServer(), rconSecret()) r, c := newReconciler(t, fakeProber{players: operator.PlayerCount{Online: 0, Max: 20, Known: true}}, runningServer(), rconSecret())
// Enable idle auto-stop with a generous timeout so we don't trigger the // Enable idle auto-stop with a generous timeout so we don't trigger the
// actual stop in this test. // actual stop in this test.
s := getServer(t, c, "survival") s := getServer(t, c, "survival")
@@ -357,7 +358,7 @@ func TestIdleAutoStop_EmptyServerGetsTimestamp(t *testing.T) {
// first reconcile stamps EmptySince; after advancing the clock past the // first reconcile stamps EmptySince; after advancing the clock past the
// timeout, the next reconcile flips desiredState to Stopped. // timeout, the next reconcile flips desiredState to Stopped.
func TestIdleAutoStop_StopsAfterTimeout(t *testing.T) { func TestIdleAutoStop_StopsAfterTimeout(t *testing.T) {
prober := fakeProber{players: operator.PlayerCount{Online: 0, Max: 20}} prober := fakeProber{players: operator.PlayerCount{Online: 0, Max: 20, Known: true}}
r, c := newReconciler(t, prober, runningServer(), rconSecret()) r, c := newReconciler(t, prober, runningServer(), rconSecret())
base := time.Date(2026, 7, 1, 10, 0, 0, 0, time.UTC) base := time.Date(2026, 7, 1, 10, 0, 0, 0, time.UTC)
@@ -397,7 +398,7 @@ func TestIdleAutoStop_StopsAfterTimeout(t *testing.T) {
// TestIdleAutoStop_ResetsWhenPlayerJoins verifies that EmptySince is cleared // TestIdleAutoStop_ResetsWhenPlayerJoins verifies that EmptySince is cleared
// when the player tally goes from zero to non-zero. // when the player tally goes from zero to non-zero.
func TestIdleAutoStop_ResetsWhenPlayerJoins(t *testing.T) { func TestIdleAutoStop_ResetsWhenPlayerJoins(t *testing.T) {
emptyProber := fakeProber{players: operator.PlayerCount{Online: 0, Max: 20}} emptyProber := fakeProber{players: operator.PlayerCount{Online: 0, Max: 20, Known: true}}
r, c := newReconciler(t, emptyProber, runningServer(), rconSecret()) r, c := newReconciler(t, emptyProber, runningServer(), rconSecret())
s := getServer(t, c, "survival") s := getServer(t, c, "survival")
@@ -417,7 +418,7 @@ func TestIdleAutoStop_ResetsWhenPlayerJoins(t *testing.T) {
} }
// Swap to a prober that reports players online. // Swap to a prober that reports players online.
populatedProber := fakeProber{players: operator.PlayerCount{Online: 3, Max: 20}} populatedProber := fakeProber{players: operator.PlayerCount{Online: 3, Max: 20, Known: true}}
r.Prober = populatedProber r.Prober = populatedProber
reconcile(t, r, "survival") reconcile(t, r, "survival")
@@ -427,13 +428,76 @@ func TestIdleAutoStop_ResetsWhenPlayerJoins(t *testing.T) {
} }
} }
// TestIdleAutoStop_UnreadTallyNeverStops pins the safety rule behind
// PlayerCount.Known: a `list` reply the parser cannot read is no sample at all.
// It must not stamp EmptySince, must not stop a server whose countdown already
// ran out, must keep the last known tally on display, and must say why on the
// PlayersCounted condition.
func TestIdleAutoStop_UnreadTallyNeverStops(t *testing.T) {
r, c := newReconciler(t, fakeProber{players: operator.PlayerCount{Online: 4, Max: 20, Known: true}}, runningServer(), rconSecret())
base := time.Date(2026, 7, 1, 10, 0, 0, 0, time.UTC)
clock := base
r.Now = func() metav1.Time { return metav1.NewTime(clock) }
s := getServer(t, c, "survival")
s.Spec.Idle = v1alpha1.IdleSpec{AutoStopEnabled: true, EmptySecondsBeforeStop: 60}
if err := c.Update(context.Background(), s); err != nil {
t.Fatalf("enable idle: %v", err)
}
reconcile(t, r, "survival")
markPodReady(t, c, "survival")
reconcile(t, r, "survival")
// The tally becomes unreadable: nothing is stamped, the last count stays.
r.Prober = fakeProber{}
reconcile(t, r, "survival")
server := getServer(t, c, "survival")
if server.Status.EmptySince != nil {
t.Fatalf("EmptySince = %v, want nil: an unread tally is not an empty server", server.Status.EmptySince)
}
if server.Status.Players.Online != 4 {
t.Errorf("players.online = %d, want the last known 4", server.Status.Players.Online)
}
cond := meta.FindStatusCondition(server.Status.Conditions, v1alpha1.ConditionPlayersCounted)
if cond == nil || cond.Status != metav1.ConditionFalse || cond.Reason != "ListUnreadable" {
t.Fatalf("PlayersCounted = %+v, want False/ListUnreadable", cond)
}
// A countdown already past its deadline does not fire on an unread tally.
r.Prober = fakeProber{players: operator.PlayerCount{Online: 0, Max: 20, Known: true}}
reconcile(t, r, "survival")
server = getServer(t, c, "survival")
cond = meta.FindStatusCondition(server.Status.Conditions, v1alpha1.ConditionPlayersCounted)
if cond == nil || cond.Status != metav1.ConditionTrue {
t.Fatalf("PlayersCounted = %+v, want True after a readable reply", cond)
}
r.Prober = fakeProber{}
clock = base.Add(10 * time.Minute)
reconcile(t, r, "survival")
server = getServer(t, c, "survival")
if server.Spec.DesiredState != v1alpha1.DesiredRunning {
t.Fatalf("desiredState = %s, want Running while the tally is unread", server.Spec.DesiredState)
}
if server.Status.EmptySince == nil {
t.Fatal("EmptySince was cleared by an unread tally; a flaky read must not restart the countdown")
}
// The next real zero stops it.
r.Prober = fakeProber{players: operator.PlayerCount{Online: 0, Max: 20, Known: true}}
reconcile(t, r, "survival")
server = getServer(t, c, "survival")
if server.Spec.DesiredState != v1alpha1.DesiredStopped {
t.Fatalf("desiredState = %s, want Stopped once a real zero is read past the deadline", server.Spec.DesiredState)
}
}
// TestIdleAutoStop_RequeuesUntilDeadline pins the self-driving requeue: an // TestIdleAutoStop_RequeuesUntilDeadline pins the self-driving requeue: an
// empty Running server must wake the controller at the auto-stop deadline with // empty Running server must wake the controller at the auto-stop deadline with
// no external event to lean on. Live, the EmptySince stamp sat unexamined for // no external event to lean on. Live, the EmptySince stamp sat unexamined for
// minutes because nothing re-triggered the reconcile loop — this test fails if // minutes because nothing re-triggered the reconcile loop — this test fails if
// the requeue is ever dropped again. // the requeue is ever dropped again.
func TestIdleAutoStop_RequeuesUntilDeadline(t *testing.T) { func TestIdleAutoStop_RequeuesUntilDeadline(t *testing.T) {
r, c := newReconciler(t, fakeProber{players: operator.PlayerCount{Online: 0, Max: 20}}, runningServer(), rconSecret()) r, c := newReconciler(t, fakeProber{players: operator.PlayerCount{Online: 0, Max: 20, Known: true}}, runningServer(), rconSecret())
base := time.Date(2026, 7, 1, 10, 0, 0, 0, time.UTC) base := time.Date(2026, 7, 1, 10, 0, 0, 0, time.UTC)
r.Now = func() metav1.Time { return metav1.NewTime(base) } r.Now = func() metav1.Time { return metav1.NewTime(base) }
@@ -456,7 +520,7 @@ func TestIdleAutoStop_RequeuesUntilDeadline(t *testing.T) {
// notices the last player leaving: with players online there is no deadline to // notices the last player leaving: with players online there is no deadline to
// aim at, but the tally must still be re-sampled. // aim at, but the tally must still be re-sampled.
func TestIdleAutoStop_RequeuesWhileOccupied(t *testing.T) { func TestIdleAutoStop_RequeuesWhileOccupied(t *testing.T) {
r, c := newReconciler(t, fakeProber{players: operator.PlayerCount{Online: 3, Max: 20}}, runningServer(), rconSecret()) r, c := newReconciler(t, fakeProber{players: operator.PlayerCount{Online: 3, Max: 20, Known: true}}, runningServer(), rconSecret())
s := getServer(t, c, "survival") s := getServer(t, c, "survival")
s.Spec.Idle = v1alpha1.IdleSpec{AutoStopEnabled: true, EmptySecondsBeforeStop: 900} s.Spec.Idle = v1alpha1.IdleSpec{AutoStopEnabled: true, EmptySecondsBeforeStop: 900}
@@ -476,7 +540,7 @@ func TestIdleAutoStop_RequeuesWhileOccupied(t *testing.T) {
// TestNoIdleRequeueWhenDisabled guards against a blanket requeue: servers // TestNoIdleRequeueWhenDisabled guards against a blanket requeue: servers
// without idle auto-stop keep the old quiescent behaviour. // without idle auto-stop keep the old quiescent behaviour.
func TestNoIdleRequeueWhenDisabled(t *testing.T) { func TestNoIdleRequeueWhenDisabled(t *testing.T) {
r, c := newReconciler(t, fakeProber{players: operator.PlayerCount{Online: 0, Max: 20}}, runningServer(), rconSecret()) r, c := newReconciler(t, fakeProber{players: operator.PlayerCount{Online: 0, Max: 20, Known: true}}, runningServer(), rconSecret())
reconcile(t, r, "survival") reconcile(t, r, "survival")
markPodReady(t, c, "survival") markPodReady(t, c, "survival")
@@ -491,7 +555,7 @@ func TestNoIdleRequeueWhenDisabled(t *testing.T) {
// not drift while the Secret is untouched — a drifting value would roll the // not drift while the Secret is untouched — a drifting value would roll the
// pod on every reconcile. // pod on every reconcile.
func TestRconStamp_StableAcrossReconciles(t *testing.T) { func TestRconStamp_StableAcrossReconciles(t *testing.T) {
r, c := newReconciler(t, fakeProber{players: operator.PlayerCount{Online: 0, Max: 20}}, runningServer(), rconSecret()) r, c := newReconciler(t, fakeProber{players: operator.PlayerCount{Online: 0, Max: 20, Known: true}}, runningServer(), rconSecret())
reconcile(t, r, "survival") reconcile(t, r, "survival")
markPodReady(t, c, "survival") markPodReady(t, c, "survival")
@@ -512,7 +576,7 @@ func TestRconStamp_StableAcrossReconciles(t *testing.T) {
// rolled onto it — otherwise the old pod keeps authenticating with the lost // rolled onto it — otherwise the old pod keeps authenticating with the lost
// password and the RCON gate fails forever. // password and the RCON gate fails forever.
func TestRconSecretRecreation_RollsTemplate(t *testing.T) { func TestRconSecretRecreation_RollsTemplate(t *testing.T) {
r, c := newReconciler(t, fakeProber{players: operator.PlayerCount{Online: 0, Max: 20}}, runningServer(), rconSecret()) r, c := newReconciler(t, fakeProber{players: operator.PlayerCount{Online: 0, Max: 20, Known: true}}, runningServer(), rconSecret())
reconcile(t, r, "survival") reconcile(t, r, "survival")
markPodReady(t, c, "survival") markPodReady(t, c, "survival")
@@ -553,7 +617,7 @@ func stsTemplateStamp(t *testing.T, c client.Client, name string) string {
// TestIdleAutoStop_SkipsWhenDisabled verifies that a Running empty server does // TestIdleAutoStop_SkipsWhenDisabled verifies that a Running empty server does
// NOT get an EmptySince timestamp when AutoStopEnabled is false. // NOT get an EmptySince timestamp when AutoStopEnabled is false.
func TestIdleAutoStop_SkipsWhenDisabled(t *testing.T) { func TestIdleAutoStop_SkipsWhenDisabled(t *testing.T) {
r, c := newReconciler(t, fakeProber{players: operator.PlayerCount{Online: 0, Max: 20}}, runningServer(), rconSecret()) r, c := newReconciler(t, fakeProber{players: operator.PlayerCount{Online: 0, Max: 20, Known: true}}, runningServer(), rconSecret())
// Idle is NOT enabled (default). // Idle is NOT enabled (default).
s := getServer(t, c, "survival") s := getServer(t, c, "survival")