diff --git a/Assets/Tests/PlayMode/PauseMenuTests.cs b/Assets/Tests/PlayMode/PauseMenuTests.cs
index 5b95b72..fc6b325 100644
--- a/Assets/Tests/PlayMode/PauseMenuTests.cs
+++ b/Assets/Tests/PlayMode/PauseMenuTests.cs
@@ -92,5 +92,177 @@ private static Behaviour RequireBehaviour(string typeName, string message)
"this file has to follow — it cannot reference the type.");
return null;
}
+
+ ///
+ /// The play-observation deadlock (T-03): pause menu → "Zum Hauptmenü"
+ /// → "Neues Spiel" left the match RUNNING (ticks advance) but every
+ /// world gesture dead. The round trip is driven through the real
+ /// entry points (MainMenuController.StartMatch / ReturnToMenu,
+ /// PauseMenuHud open/close via reflection — the assembly may not be
+ /// referenced, so the button layer is not under test here), and after
+ /// EVERY leg the input path must still answer: a click on the start
+ /// field must select it (the FieldReservePickTests probe). Each leg
+ /// logs the three gate states, so a failure names the stuck one:
+ /// ModalSurfaceLink, menu visibility, HUD-root activity.
+ ///
+ [UnityTest]
+ public IEnumerator PauseRoundTrip_ThroughMainMenu_KeepsInputAlive()
+ {
+ yield return SceneManager.LoadSceneAsync(ScenePath, LoadSceneMode.Single);
+ yield return null;
+ yield return null;
+
+ Behaviour menu = RequireBehaviour("MainMenuController", "scene contains no main menu controller");
+ Behaviour input = RequireBehaviour("RtsDeviceInput", "scene contains no device input");
+ Behaviour pause = RequireBehaviour(PauseMenuTypeName, "scene contains no pause menu");
+
+ // Leg 1: menu → match.
+ Invoke(menu, "StartMatch");
+ yield return new WaitForSeconds(0.5f);
+ LogGates("after StartMatch", menu, input);
+ AssertFieldPickWorks(input, "input dead right after StartMatch");
+
+ // Leg 2: open and close the pause menu.
+ Invoke(pause, "OpenMenu");
+ yield return null;
+ yield return null;
+ Assert.IsTrue(ModalSurfaceLink.Open, "an open pause menu must claim the modal channel");
+ Invoke(pause, "CloseMenu", false);
+ yield return null;
+ yield return null;
+ LogGates("after pause close", menu, input);
+ Assert.IsFalse(ModalSurfaceLink.Open, "closing the pause menu must release the modal channel");
+ AssertFieldPickWorks(input, "input dead after closing the pause menu");
+
+ // Leg 3: pause → "Zum Hauptmenü" → "Neues Spiel" (the T-03 path).
+ // The button's exact semantics: drop the menu state WITHOUT
+ // resuming the clock, then ReturnToMenu.
+ Invoke(pause, "OpenMenu");
+ yield return null;
+ SetPrivateField(pause, "_menuOpen", false);
+ SetPrivateField(pause, "_pausedByMenu", false);
+ Invoke(menu, "ReturnToMenu");
+ yield return null;
+ yield return null;
+ LogGates("after ReturnToMenu", menu, input);
+ Invoke(menu, "StartMatch");
+ yield return new WaitForSeconds(0.5f);
+ LogGates("after second StartMatch", menu, input);
+ AssertFieldPickWorks(input, "input dead after menu → new-match round trip (T-03)");
+ }
+
+ private static void SetPrivateField(object target, string field, object value)
+ {
+ System.Reflection.FieldInfo info = target.GetType().GetField(
+ field, System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
+ Assert.NotNull(info, $"{target.GetType().Name}.{field} not found");
+ info.SetValue(target, value);
+ }
+
+ private static void LogGates(string where, Behaviour menu, Behaviour input)
+ {
+ bool menuVisible = (bool)menu.GetType().GetProperty("IsMenuVisible").GetValue(menu);
+ Debug.Log($"[PauseRoundTrip] {where}: ModalSurfaceLink.Open={ModalSurfaceLink.Open}, " +
+ $"IsMenuVisible={menuVisible}, input GO active={input.gameObject.activeInHierarchy}, " +
+ $"input enabled={input.enabled}");
+ }
+
+ private static void AssertFieldPickWorks(Behaviour input, string message)
+ {
+ if (!input.gameObject.activeInHierarchy || !input.enabled)
+ {
+ Assert.Fail($"{message} — the input component itself is off (HUD root switch)");
+ }
+ Camera camera = Camera.main;
+ Vector3 screen = camera.WorldToScreenPoint(new Vector3(7.5f, 0f, 7.5f));
+ input.GetType()
+ .GetMethod("SelectSingle", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)
+ .Invoke(input, new object[] { new Vector2(screen.x, screen.y), false });
+ var selection = (SelectionManager)input.GetType().GetProperty("Selection").GetValue(input);
+ Assert.AreEqual((ushort)1, selection.SelectedFieldId, message);
+ }
+
+ private static void Invoke(Behaviour target, string method, params object[] args)
+ {
+ System.Reflection.MethodInfo info = target.GetType().GetMethod(
+ method,
+ System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
+ Assert.NotNull(info, $"{target.GetType().Name}.{method} not found");
+ info.Invoke(target, args);
+ }
+
+ ///
+ /// The actual T-03 defect ("after pause, units no longer move, but
+ /// buildings still complete"): MatchRunner.StartMatch is the resume
+ /// path, and Kernel.Start() with its default argument resets
+ /// CurrentTick to 0 while the session and all systems keep their
+ /// state — player commands, targeted at session ticks, then land
+ /// minutes late or never. The pin: a move order must move the unit
+ /// BEFORE and AFTER a pause/resume, and the kernel tick must never
+ /// jump backwards across it.
+ ///
+ [UnityTest]
+ public IEnumerator PauseResume_KeepsTheTickAndCommandsFlowing()
+ {
+ yield return SceneManager.LoadSceneAsync(ScenePath, LoadSceneMode.Single);
+ yield return null;
+ yield return null;
+
+ Behaviour menu = RequireBehaviour("MainMenuController", "scene contains no main menu controller");
+ Invoke(menu, "StartMatch");
+ yield return new WaitForSeconds(0.5f);
+
+ var bootstrap = Object.FindAnyObjectByType();
+ Assert.NotNull(bootstrap, "no MatchBootstrap");
+ Nova.Gameplay.Match.MatchRunner runner = bootstrap.Runner;
+ Assert.IsTrue(runner.IsRunning, "match not running after StartMatch");
+
+ // The local Builder of the D-077 opening.
+ Nova.Core.EntityId builder = Nova.Core.EntityId.Invalid;
+ Nova.Simulation.State.UnitState[] units = runner.Entities.RawUnits;
+ for (int i = 0; i < runner.Entities.Capacity; i++)
+ {
+ if (units[i].IsActive && units[i].PlayerId == 0 && units[i].Role == Nova.Simulation.State.UnitRole.Builder)
+ {
+ builder = units[i].Id;
+ break;
+ }
+ }
+ Assert.IsTrue(builder.IsValid, "no local Builder in the opening");
+
+ float before = units[builder.Index].Transform.PositionX.ToFloat();
+ SubmitMove(runner, builder, 6f);
+ yield return new WaitForSeconds(1.5f);
+ float afterFirst = units[builder.Index].Transform.PositionX.ToFloat();
+ Assert.Greater(afterFirst - before, 0.5f, "the move order before the pause must move the Builder");
+
+ Assert.IsTrue(runner.PauseMatch(), "local pause refused");
+ uint tickAtPause = runner.Kernel.CurrentTick.Value;
+ yield return new WaitForSeconds(0.3f);
+ Assert.IsTrue(runner.StartMatch(), "resume refused");
+ yield return null;
+ Assert.GreaterOrEqual(runner.Kernel.CurrentTick.Value, tickAtPause,
+ "the kernel tick jumped backwards across pause/resume — the T-03 defect");
+
+ SubmitMove(runner, builder, 3f);
+ yield return new WaitForSeconds(1.5f);
+ float afterResume = units[builder.Index].Transform.PositionX.ToFloat();
+ Assert.Greater(Mathf.Abs(afterResume - afterFirst), 0.5f,
+ "the move order after pause/resume must still reach the sim (T-03)");
+ }
+
+ /// Submits a move order for the unit through the sealed intake, target = current X + delta.
+ private static void SubmitMove(Nova.Gameplay.Match.MatchRunner runner, Nova.Core.EntityId unit, float deltaX)
+ {
+ Nova.Simulation.State.UnitState[] units = runner.Entities.RawUnits;
+ float x = units[unit.Index].Transform.PositionX.ToFloat() + deltaX;
+ float y = units[unit.Index].Transform.PositionY.ToFloat();
+ var payload = new Nova.Simulation.CommandsV1.MovePayload(
+ new[] { Nova.Simulation.State.UnitCommandStateView.ToRawEntityId(unit) },
+ Nova.Core.SimFixed.FromFloat(x), Nova.Core.SimFixed.FromFloat(y));
+ Assert.AreEqual(Nova.Simulation.CommandsV1.CommandIngressResult.Accepted,
+ runner.Ingress.TrySubmitIntent(Nova.Simulation.CommandsV1.CommandIntent.Create(payload), out _),
+ "the move intent must enter the sealed intake");
+ }
}
}
diff --git a/Assets/_Project/Scripts/Gameplay/Match/MatchRunner.cs b/Assets/_Project/Scripts/Gameplay/Match/MatchRunner.cs
index 7e104fc..41da921 100644
--- a/Assets/_Project/Scripts/Gameplay/Match/MatchRunner.cs
+++ b/Assets/_Project/Scripts/Gameplay/Match/MatchRunner.cs
@@ -286,9 +286,18 @@ public void InitializeMatch(MatchConfig config)
}
///
- /// Starts a freshly initialized kernel. A relay-backed kernel may be
- /// started exactly once: restarting it would reset the simulation
- /// tick while the remote peer keeps advancing.
+ /// Starts a freshly initialized kernel — and RESUMES a paused one at
+ /// its standing tick. The tick is handed in explicitly because
+ /// defaults to 0: a bare call
+ /// on the resume path (T-03) rewound the kernel while the session
+ /// and every system kept their state, and player commands targeted
+ /// at session ticks then landed minutes late or never — units
+ /// "stopped moving" after pause while state-driven systems (sites,
+ /// production) kept running. A fresh kernel stands at tick 0, so
+ /// passing the current tick is correct for both paths. A
+ /// relay-backed kernel may be started exactly once: restarting it
+ /// would reset the simulation tick while the remote peer keeps
+ /// advancing.
///
public bool StartMatch()
{
@@ -305,7 +314,7 @@ public bool StartMatch()
}
_timeAccumulator = 0f;
- Kernel.Start();
+ Kernel.Start(Kernel.CurrentTick);
_kernelStarted = true;
return true;
}
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e9561d7..9437bc8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -305,6 +305,15 @@ die Versionierung folgt (in der aktuellen Doku-Phase) dem Dokumentationsstand de
(exakte Rechtecks-Chebyshev-Boxen statt Texel-Scan, Äquivalenz über das
ganze Raster gepinnt); der Repaint liest nur noch. Selbes Bild, kein
Hitch. Simulationsverhalten unverändert, keine Baseline bewegt
+- **Nach Pause/Resume nahmen Einheiten keine Befehle mehr an (T-03).**
+ `MatchRunner.StartMatch` ist auch der Resume-Pfad des Pausemenüs und rief
+ `Kernel.Start()` mit dem Default-Tick 0 auf — der Kernel sprang zurück,
+ während Session und Systeme ihren Stand behielten: danach eingereichte
+ Spielerbefehle kamen Minuten spät oder nie an, während zustandsgetriebene
+ Systeme (Baustellen, Produktion) sichtbar weiterliefen. Jetzt resume't der
+ Kernel bei seinem stehenden Tick; ein PlayMode-Test pinnt, dass der Tick
+ über Pause/Resume nicht zurückspringt und Bewegungsbefehle danach
+ weiterhin ankommen
- **#85: Die KI erntet nicht länger endlos auf dem leeren Feld.** Aus dem
Betatest vom 10.08.2026: die KI kam nach Erschöpfung ihres Startvorkommens
wirtschaftlich zum Stillstand. Das war kein Strategiemangel, sondern ein