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