Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
172 changes: 172 additions & 0 deletions Assets/Tests/PlayMode/PauseMenuTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -92,5 +92,177 @@ private static Behaviour RequireBehaviour(string typeName, string message)
"this file has to follow — it cannot reference the type.");
return null;
}

/// <summary>
/// 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.
/// </summary>
[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);
}

/// <summary>
/// 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.
/// </summary>
[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<Nova.Gameplay.Match.MatchBootstrap>();
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)");
}

/// <summary>Submits a move order for the unit through the sealed intake, target = current X + delta.</summary>
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");
}
}
}
17 changes: 13 additions & 4 deletions Assets/_Project/Scripts/Gameplay/Match/MatchRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -286,9 +286,18 @@ public void InitializeMatch(MatchConfig config)
}

/// <summary>
/// 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
/// <see cref="SimulationKernel.Start()"/> 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.
/// </summary>
public bool StartMatch()
{
Expand All @@ -305,7 +314,7 @@ public bool StartMatch()
}

_timeAccumulator = 0f;
Kernel.Start();
Kernel.Start(Kernel.CurrentTick);
_kernelStarted = true;
return true;
}
Expand Down
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading