From bd909d5858b897692a1300431a70f669ea6fe0b3 Mon Sep 17 00:00:00 2001 From: Lemon-miaow Date: Thu, 24 Sep 2026 16:06:55 +0800 Subject: [PATCH] =?UTF-8?q?fix(operator):=20=E8=AF=BB=E4=B8=8D=E5=87=BA?= =?UTF-8?q?=E7=8E=A9=E5=AE=B6=E6=95=B0=E6=97=B6=E6=9A=82=E5=81=9C=E7=A9=BA?= =?UTF-8?q?=E9=97=B2=E8=87=AA=E5=8A=A8=E5=81=9C=E6=9C=8D=EF=BC=8C=E6=94=AF?= =?UTF-8?q?=E6=8C=81=201.12/Bukkit/EssentialsX=20=E4=B8=8E=E9=A2=9C?= =?UTF-8?q?=E8=89=B2=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/troubleshooting.md | 41 ++++++--- .../felis/v1alpha1/minecraftserver_types.go | 3 + internal/operator/prober.go | 78 ++++++++++++----- internal/operator/prober_internal_test.go | 36 +++++++- internal/operator/reconciler.go | 26 ++++-- internal/operator/reconciler_test.go | 86 ++++++++++++++++--- 6 files changed, 213 insertions(+), 57 deletions(-) diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 1192337..8b19354 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -716,24 +716,37 @@ Check it first. kubectl get minecraftserver -o jsonpath='{.spec.rcon.enabled}' ``` -The player tally is a by-product of the RCON readiness probe — `prober.go:63` -runs `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 -RCON disabled the probe never runs, `players` keeps its zero value, and -`markRunningReady` (`reconciler.go:413`) writes that zero into -`status.players.online`. So a permanent 0 means "never sampled", not "nobody -online". +The player tally is a by-product of the RCON readiness probe: `prober.go` runs +`list` on the same connection that just authenticated, and `parseListReply` +reads the tally from the vanilla/Paper/Fabric/Forge reply (`There are 3 of a +max of 20 players online`), the 1.12/Bukkit reply (`There are 3/20 players +online`) or the EssentialsX reply (`There are 3 out of maximum 20 players +online`, vanished players included), with `§` color codes stripped. With RCON +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 -carries the RCON condition explicitly: +A tally that could not be read counts as **unknown**, never as zero. Idle +auto-stop only acts on a count it actually read: ```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 -'empty' and use to stop a server full of people". So the guard is deliberate — -enabling `spec.idle.*` without RCON is a no-op by design, not a missing feature. +An unknown tally leaves `status.players` at the last real count, neither starts +nor clears the empty countdown, and sets the `PlayersCounted` condition to +`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 -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 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 | | Registry push/pull unreachable | §9 | | 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 | | PVC left behind after delete | §13 | | Node out of disk; pods evicted / ImagePullBackOff | §13b | diff --git a/internal/apis/felis/v1alpha1/minecraftserver_types.go b/internal/apis/felis/v1alpha1/minecraftserver_types.go index 0c80348..25dc62a 100644 --- a/internal/apis/felis/v1alpha1/minecraftserver_types.go +++ b/internal/apis/felis/v1alpha1/minecraftserver_types.go @@ -72,6 +72,9 @@ const ( ConditionReady = "Ready" ConditionRconReached = "RconReached" 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 diff --git a/internal/operator/prober.go b/internal/operator/prober.go index ea9d552..755b562 100644 --- a/internal/operator/prober.go +++ b/internal/operator/prober.go @@ -10,17 +10,20 @@ import ( ) // 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). +// Known is false when the count could not be read (the command failed, or its +// 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 { Online int32 Max int32 + Known bool } // 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 +// loader-agnostic readiness gate (spec §5); the PlayerCount is advisory and has +// 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. type Prober interface { 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 // 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, +// 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. func (p RconProber) Probe(ctx context.Context, addr, password string) (PlayerCount, error) { timeout := p.Timeout @@ -68,26 +71,55 @@ func (p RconProber) Probe(ctx context.Context, addr, password string) (PlayerCou 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`) +// listReplyPatterns match the `list` replies of the loaders Felis runs, tried in +// order against the reply with § color codes stripped: +// +// - 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 -// (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". +// (and the PlayerCount unknown) when the reply matches none of the known +// formats, 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 + plain := colorCode.ReplaceAllString(reply, "") + for _, re := range listReplyPatterns { + 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) - 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 + return PlayerCount{}, false } diff --git a/internal/operator/prober_internal_test.go b/internal/operator/prober_internal_test.go index 5bace20..54feb48 100644 --- a/internal/operator/prober_internal_test.go +++ b/internal/operator/prober_internal_test.go @@ -12,21 +12,51 @@ func TestParseListReply(t *testing.T) { { name: "empty server", 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, }, { name: "with player names", 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, }, { name: "full server", 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, }, + { + 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", reply: "Unknown command. Try /help for a list of commands.", diff --git a/internal/operator/reconciler.go b/internal/operator/reconciler.go index ed5bf11..864cffd 100644 --- a/internal/operator/reconciler.go +++ b/internal/operator/reconciler.go @@ -217,16 +217,27 @@ func (r *Reconciler) reconcileRunning(ctx context.Context, server *v1alpha1.Mine return ctrl.Result{RequeueAfter: requeueStarting}, nil } 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 // player tally is zero, track the empty duration and auto-stop when the // configured timeout expires. The existing RCON probe already supplies // the player count — no extra network cost. - // Rcon.Enabled is part of the condition because `players` is only a real tally - // when the probe above ran: with RCON off it keeps its zero value, which this - // branch would read as "empty" and use to stop a server full of people. - if server.Spec.Rcon.Enabled && server.Spec.Idle.AutoStopEnabled && server.Spec.Idle.EmptySecondsBeforeStop > 0 { + // players.Known gates the whole branch: an unread tally (RCON off, `list` + // failed, or a reply format the parser does not know) must never read as + // "empty" and stop a server full of people. It neither stamps nor clears + // 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 server.Status.EmptySince == nil { t := r.now() @@ -525,8 +536,11 @@ func (r *Reconciler) markRunningReady(server *v1alpha1.MinecraftServer, players server.Status.Ready = true server.Status.ObservedGeneration = server.Generation // 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. - server.Status.Players = v1alpha1.PlayersStatus{Online: players.Online, Max: players.Max} + // reports live occupancy instead of the 0/0 markStopped leaves behind. An + // 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 { t := r.now() server.Status.ReadySignalAt = &t diff --git a/internal/operator/reconciler_test.go b/internal/operator/reconciler_test.go index 86fd3e0..6940e54 100644 --- a/internal/operator/reconciler_test.go +++ b/internal/operator/reconciler_test.go @@ -14,6 +14,7 @@ import ( appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" @@ -254,7 +255,7 @@ func TestReconcileRunning_RconProbeGatesReadiness(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 markPodReady(t, c, "survival") @@ -331,7 +332,7 @@ func TestReconcileStopped_ScalesRunningWorkloadDown(t *testing.T) { // TestIdleAutoStop_EmptyServerGetsTimestamp verifies that the first Running // reconcile with zero players stamps EmptySince and keeps the server Running. 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 // actual stop in this test. 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 // timeout, the next reconcile flips desiredState to Stopped. 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()) 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 // when the player tally goes from zero to non-zero. 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()) s := getServer(t, c, "survival") @@ -417,7 +418,7 @@ func TestIdleAutoStop_ResetsWhenPlayerJoins(t *testing.T) { } // 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 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 // 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 // minutes because nothing re-triggered the reconcile loop — this test fails if // the requeue is ever dropped again. 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) 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 // aim at, but the tally must still be re-sampled. 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.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 // without idle auto-stop keep the old quiescent behaviour. 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") 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 // pod on every reconcile. 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") 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 // password and the RCON gate fails forever. 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") 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 // NOT get an EmptySince timestamp when AutoStopEnabled is false. 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). s := getServer(t, c, "survival")