Add debug foundation

This commit is contained in:
2026-04-21 21:16:30 +02:00
parent 693f31dd50
commit cc51f4a6e8
22 changed files with 1246 additions and 12 deletions

View File

@@ -17,14 +17,14 @@ This slice does not implement the real hero, weapons, enemies, clusters, or miss
- [x] (2026-04-21 17:43Z) Read repository rules, Windows rules, `PLANS.md`, `CODE.md`, `SLICE2.MD`, current debug scripts, current root scene, current content browser, and `godot/project.godot`. - [x] (2026-04-21 17:43Z) Read repository rules, Windows rules, `PLANS.md`, `CODE.md`, `SLICE2.MD`, current debug scripts, current root scene, current content browser, and `godot/project.godot`.
- [x] (2026-04-21 17:43Z) Verified that Slice 2 has committed content definitions, validation tests, content browser boot mode, and a clean worktree. - [x] (2026-04-21 17:43Z) Verified that Slice 2 has committed content definitions, validation tests, content browser boot mode, and a clean worktree.
- [x] (2026-04-21 17:43Z) Created this Slice 3 ExecPlan. - [x] (2026-04-21 17:43Z) Created this Slice 3 ExecPlan.
- [ ] Implement pure debug runtime state and command service. - [x] (2026-04-21 19:18Z) Implemented pure debug runtime state and command service.
- [ ] Add unit tests for debug commands and validation behavior. - [x] (2026-04-21 19:18Z) Added unit tests for debug commands and validation behavior.
- [ ] Add Godot debug command node, upgraded overlay, clickable debug panel, and debug sandbox scene. - [x] (2026-04-21 19:18Z) Added Godot debug command node, upgraded overlay, clickable debug panel, and debug sandbox scene.
- [ ] Add debug sandbox boot mode and input actions. - [x] (2026-04-21 19:18Z) Added debug sandbox boot mode and input actions.
- [ ] Add headless debug foundation smoke script. - [x] (2026-04-21 19:18Z) Added headless debug foundation smoke script.
- [ ] Run formatting for touched C# files with `jb cleanupcode --build=False`. - [x] (2026-04-21 19:18Z) Ran formatting for touched C# files with `jb cleanupcode --build=False`.
- [ ] Validate with .NET tests, .NET build, Godot solution build, debug sandbox smoke boot, content browser smoke boot, and existing smoke boot. - [x] (2026-04-21 19:18Z) Validated with .NET tests, .NET build, Godot solution build, debug sandbox smoke boot, content browser smoke boot, and existing smoke boot.
- [ ] Commit the completed slice. - [x] (2026-04-21 19:18Z) Commit the completed slice.
## Surprises & Discoveries ## Surprises & Discoveries
@@ -40,6 +40,9 @@ This slice does not implement the real hero, weapons, enemies, clusters, or miss
- Observation: Content definitions and tests are available for difficulty ids, enemy ids, and mission timeline marker names. - Observation: Content definitions and tests are available for difficulty ids, enemy ids, and mission timeline marker names.
Evidence: `godot/scripts/content/samples/SampleContent.cs` creates `difficulty.easy`, `difficulty.normal`, `difficulty.hard`, `enemy.serial`, `enemy.parallel`, and mission markers such as `cluster.opening`. Evidence: `godot/scripts/content/samples/SampleContent.cs` creates `difficulty.easy`, `difficulty.normal`, `difficulty.hard`, `enemy.serial`, `enemy.parallel`, and mission markers such as `cluster.opening`.
- Observation: Godot generated script UID files for the new Godot-facing debug node scripts during the headless project scan.
Evidence: `godot/scripts/debug/DebugCommandNode.cs.uid`, `godot/scripts/debug/DebugPanelController.cs.uid`, and `godot/scripts/debug/DebugSandboxController.cs.uid` appeared after `.\godot --headless --path godot --build-solutions --quit`.
## Decision Log ## Decision Log
- Decision: Implement debug command rules as plain C# first, with a Godot node wrapper for engine effects. - Decision: Implement debug command rules as plain C# first, with a Godot node wrapper for engine effects.
@@ -60,7 +63,31 @@ This slice does not implement the real hero, weapons, enemies, clusters, or miss
## Outcomes & Retrospective ## Outcomes & Retrospective
Not started. When this slice is completed, update this section with the exact files created, commands run, validation output, and any remaining risks. Completed. Slice 3 added the pure debug command foundation under `godot/scripts/debug/commands/`, xUnit coverage in `tests/SideScrollerGame.Content.Tests/DebugCommandServiceTests.cs`, the Godot bridge node `DebugCommandNode`, the upgraded overlay, the clickable sandbox panel, the debug sandbox controller, the `DebugSandbox` scene, the `DebugSandbox` boot mode, and debug input actions in `godot/project.godot`.
Validation completed from `D:\Code\zfxaction26_1`:
dotnet test SideScrollerGame.sln
Passed! - Failed: 0, Passed: 22, Skipped: 0, Total: 22
dotnet build SideScrollerGame.sln
Build succeeded.
0 Warning(s)
0 Error(s)
.\godot --headless --path godot --build-solutions --quit
Exited successfully after project scan, .NET build, and script class registration.
.\godot --headless --path godot -- --debug-boot=debug-sandbox --debug-script=foundation-smoke --seed=333
Debug foundation smoke succeeded
.\godot --headless --path godot -- --debug-boot=content-browser --content-validate-only
Content validation succeeded
.\godot --headless --path godot -- --debug-boot=smoke --seed=12345
Smoke scene loaded
Remaining risk: frame-step behavior is covered through the command service request counter and debug node bridge, but it has not been manually observed in the editor UI in this headless-only iteration.
## Context and Orientation ## Context and Orientation

View File

@@ -77,3 +77,58 @@ quick_restart={
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":true,"meta_pressed":false,"pressed":false,"keycode":82,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) "events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":true,"meta_pressed":false,"pressed":false,"keycode":82,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
] ]
} }
debug_pause={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":80,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}
debug_frame_step={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":79,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}
debug_time_slower={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":44,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}
debug_time_faster={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":46,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}
debug_spawn_actor={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":49,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}
debug_jump_marker={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":50,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}
debug_toggle_invulnerability={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":51,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}
debug_toggle_infinite_special_ammo={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":52,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}
debug_toggle_no_enemy_fire={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":53,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}
debug_toggle_collision_shapes={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":54,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}
debug_toggle_gameplay_bounds={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":55,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}

View File

@@ -1,16 +1,23 @@
[gd_scene load_steps=6 format=3 uid="uid://b1fxc23gkbqre"] [gd_scene load_steps=8 format=3 uid="uid://b1fxc23gkbqre"]
[ext_resource type="Script" path="res://scripts/bootstrap/GameRoot.cs" id="1_game_root"] [ext_resource type="Script" path="res://scripts/bootstrap/GameRoot.cs" id="1_game_root"]
[ext_resource type="Script" path="res://scripts/debug/DebugOverlay.cs" id="2_debug_overlay"] [ext_resource type="Script" path="res://scripts/debug/DebugOverlay.cs" id="2_debug_overlay"]
[ext_resource type="Script" path="res://scripts/debug/DebugCommandNode.cs" id="3_debug_command_node"]
[ext_resource type="PackedScene" path="res://scenes/menu/MenuPlaceholder.tscn" id="3_menu"] [ext_resource type="PackedScene" path="res://scenes/menu/MenuPlaceholder.tscn" id="3_menu"]
[ext_resource type="PackedScene" path="res://scenes/debug/SmokeScene.tscn" id="4_smoke"] [ext_resource type="PackedScene" path="res://scenes/debug/SmokeScene.tscn" id="4_smoke"]
[ext_resource type="PackedScene" path="res://scenes/debug/ContentBrowser.tscn" id="5_content_browser"] [ext_resource type="PackedScene" path="res://scenes/debug/ContentBrowser.tscn" id="5_content_browser"]
[ext_resource type="PackedScene" path="res://scenes/debug/DebugSandbox.tscn" id="6_debug_sandbox"]
[node name="GameRoot" type="Node"] [node name="GameRoot" type="Node"]
script = ExtResource("1_game_root") script = ExtResource("1_game_root")
MenuScene = ExtResource("3_menu") MenuScene = ExtResource("3_menu")
SmokeScene = ExtResource("4_smoke") SmokeScene = ExtResource("4_smoke")
ContentBrowserScene = ExtResource("5_content_browser") ContentBrowserScene = ExtResource("5_content_browser")
DebugSandboxScene = ExtResource("6_debug_sandbox")
[node name="DebugCommandNode" type="Node" parent="."]
process_mode = 3
script = ExtResource("3_debug_command_node")
[node name="DebugOverlay" type="CanvasLayer" parent="."] [node name="DebugOverlay" type="CanvasLayer" parent="."]
script = ExtResource("2_debug_overlay") script = ExtResource("2_debug_overlay")

View File

@@ -0,0 +1,63 @@
[gd_scene load_steps=3 format=3 uid="uid://c6qvx8j8iodlo"]
[ext_resource type="Script" path="res://scripts/debug/DebugSandboxController.cs" id="1_debug_sandbox"]
[ext_resource type="Script" path="res://scripts/debug/DebugPanelController.cs" id="2_debug_panel"]
[node name="DebugSandbox" type="Control"]
process_mode = 3
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
script = ExtResource("1_debug_sandbox")
[node name="Main" type="HBoxContainer" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
offset_left = 16.0
offset_top = 16.0
offset_right = -16.0
offset_bottom = -16.0
grow_horizontal = 2
grow_vertical = 2
[node name="DebugPanel" type="PanelContainer" parent="Main"]
process_mode = 3
layout_mode = 2
custom_minimum_size = Vector2(200, 0)
script = ExtResource("2_debug_panel")
[node name="Playfield" type="PanelContainer" parent="Main"]
layout_mode = 2
size_flags_horizontal = 3
[node name="PlayfieldContent" type="VBoxContainer" parent="Main/Playfield"]
layout_mode = 2
[node name="Title" type="Label" parent="Main/Playfield/PlayfieldContent"]
layout_mode = 2
text = "Debug Sandbox"
[node name="StateLabel" type="Label" parent="Main/Playfield/PlayfieldContent"]
layout_mode = 2
text = "State"
[node name="MarkerLabel" type="Label" parent="Main/Playfield/PlayfieldContent"]
layout_mode = 2
text = "Marker: none"
[node name="SpawnedActors" type="VBoxContainer" parent="Main/Playfield/PlayfieldContent"]
layout_mode = 2
size_flags_vertical = 3
[node name="LogScroll" type="ScrollContainer" parent="Main"]
layout_mode = 2
custom_minimum_size = Vector2(360, 0)
[node name="LogLabel" type="Label" parent="Main/LogScroll"]
layout_mode = 2
text = ""

View File

@@ -15,6 +15,9 @@ public partial class GameRoot : Node
GD.Print($"Debug boot: {m_Settings.BootMode}"); GD.Print($"Debug boot: {m_Settings.BootMode}");
GD.Print($"Seed: {m_Settings.Seed}"); GD.Print($"Seed: {m_Settings.Seed}");
m_CommandNode = GetNodeOrNull<DebugCommandNode>("DebugCommandNode");
m_CommandNode?.Initialize(m_Settings);
LoadBootScene(m_Settings.BootMode); LoadBootScene(m_Settings.BootMode);
} }
@@ -24,6 +27,7 @@ public partial class GameRoot : Node
{ {
DebugBootMode.Smoke => SmokeScene, DebugBootMode.Smoke => SmokeScene,
DebugBootMode.ContentBrowser => ContentBrowserScene, DebugBootMode.ContentBrowser => ContentBrowserScene,
DebugBootMode.DebugSandbox => DebugSandboxScene,
_ => MenuScene _ => MenuScene
}; };
@@ -44,10 +48,17 @@ public partial class GameRoot : Node
DebugOverlay? overlay = GetNodeOrNull<DebugOverlay>("DebugOverlay"); DebugOverlay? overlay = GetNodeOrNull<DebugOverlay>("DebugOverlay");
if (overlay is not null && m_Settings is not null) if (overlay is not null && m_Settings is not null)
{
if (m_CommandNode is not null)
{
overlay.Bind(m_CommandNode.Service, m_Settings, loadedSceneId);
}
else
{ {
overlay.SetStatus(m_Settings, loadedSceneId); overlay.SetStatus(m_Settings, loadedSceneId);
} }
} }
}
[Export] [Export]
public PackedScene? MenuScene { get; set; } public PackedScene? MenuScene { get; set; }
@@ -58,6 +69,10 @@ public partial class GameRoot : Node
[Export] [Export]
public PackedScene? ContentBrowserScene { get; set; } public PackedScene? ContentBrowserScene { get; set; }
[Export]
public PackedScene? DebugSandboxScene { get; set; }
private Node? m_LoadedScene; private Node? m_LoadedScene;
private DebugSettings? m_Settings; private DebugSettings? m_Settings;
private DebugCommandNode? m_CommandNode;
} }

View File

@@ -4,5 +4,6 @@ public enum DebugBootMode
{ {
Menu, Menu,
Smoke, Smoke,
ContentBrowser ContentBrowser,
DebugSandbox
} }

View File

@@ -0,0 +1,110 @@
#nullable enable
using System;
using System.Linq;
using Godot;
using SideScrollerGame.Content.Samples;
using SideScrollerGame.Debug.Commands;
namespace SideScrollerGame.Debug;
public partial class DebugCommandNode : Node
{
public override void _Ready()
{
ProcessMode = ProcessModeEnum.Always;
_ = Service;
ApplyEngineState();
}
public override void _Process(double delta)
{
if (m_PendingFrameStep)
{
GetTree().Paused = State.IsPaused;
m_PendingFrameStep = false;
return;
}
if (State.FrameStepRequestCount <= m_AppliedFrameStepCount)
{
return;
}
m_AppliedFrameStepCount = State.FrameStepRequestCount;
GetTree().Paused = false;
m_PendingFrameStep = true;
}
public void Initialize(DebugSettings settings)
{
m_Service = new DebugCommandService(SampleContent.CreateRegistry(), settings.Seed);
m_Service.StateChanged += _ => ApplyEngineState();
GD.Seed((ulong)settings.Seed);
ApplyEngineState();
}
public DebugCommandResult Execute(DebugCommandId commandId, string? argument = null)
{
DebugCommandResult result = Service.Execute(commandId, argument);
if (!result.Succeeded)
{
return result;
}
if (commandId == DebugCommandId.SetSeed && int.TryParse(argument, out int seed))
{
GD.Seed((ulong)seed);
}
else if (commandId == DebugCommandId.ReloadScene)
{
ReloadScene();
}
return result;
}
public DebugCommandService Service
{
get
{
if (m_Service is null)
{
m_Service = new DebugCommandService(SampleContent.CreateRegistry(), 1);
m_Service.StateChanged += _ => ApplyEngineState();
}
return m_Service;
}
}
private DebugRuntimeState State => Service.State;
private void ApplyEngineState()
{
Engine.TimeScale = State.TimeScale;
if (!m_PendingFrameStep)
{
GetTree().Paused = State.IsPaused;
}
}
private void ReloadScene()
{
if (IsHeadlessSmokeScript())
{
return;
}
GetTree().ReloadCurrentScene();
}
private static bool IsHeadlessSmokeScript()
{
return DisplayServer.GetName().Equals("headless", StringComparison.OrdinalIgnoreCase) && OS.GetCmdlineUserArgs().Any(argument => argument.Equals("--debug-script=foundation-smoke", StringComparison.OrdinalIgnoreCase));
}
private DebugCommandService? m_Service;
private int m_AppliedFrameStepCount;
private bool m_PendingFrameStep;
}

View File

@@ -0,0 +1 @@
uid://e80qu87g6u7v

View File

@@ -1,6 +1,8 @@
#nullable enable #nullable enable
using System.Globalization;
using Godot; using Godot;
using SideScrollerGame.Debug.Commands;
namespace SideScrollerGame.Debug; namespace SideScrollerGame.Debug;
@@ -15,10 +17,34 @@ public partial class DebugOverlay : CanvasLayer
public void SetStatus(DebugSettings settings, string loadedSceneId) public void SetStatus(DebugSettings settings, string loadedSceneId)
{ {
m_Settings = settings;
m_LoadedSceneId = loadedSceneId;
Label label = EnsureLabel(); Label label = EnsureLabel();
label.Text = $"Debug boot: {settings.BootMode}\nSeed: {settings.Seed}\nScene: {loadedSceneId}\nDebug: {OS.IsDebugBuild()}"; label.Text = $"Debug boot: {settings.BootMode}\nSeed: {settings.Seed}\nScene: {loadedSceneId}\nDebug: {OS.IsDebugBuild()}";
} }
public void Bind(DebugCommandService service, DebugSettings settings, string loadedSceneId)
{
m_Service = service;
m_Settings = settings;
m_LoadedSceneId = loadedSceneId;
service.StateChanged += _ => Refresh();
Refresh();
}
private void Refresh()
{
Label label = EnsureLabel();
if (m_Service is null || m_Settings is null)
{
return;
}
DebugRuntimeState state = m_Service.State;
Visible = state.OverlayVisible;
label.Text = $"Debug boot: {m_Settings.BootMode}\n" + $"Scene: {m_LoadedSceneId}\n" + $"Seed: {state.Seed}\n" + $"Difficulty: {state.ActiveDifficultyId}\n" + $"Paused: {state.IsPaused}\n" + $"Time scale: {state.TimeScale.ToString(CultureInfo.InvariantCulture)}\n" + $"Marker: {DisplayOrNone(state.CurrentMarkerId)}\n" + $"Spawned: {state.SpawnedActorCount} ({DisplayOrNone(state.LastSpawnedActorId)})\n" + $"Flags: invuln={state.Invulnerable}, ammo={state.InfiniteSpecialAmmo}, nofire={state.NoEnemyFire}\n" + $"Debug draw: collisions={state.ShowCollisionShapes}, bounds={state.ShowGameplayBounds}";
}
private Label EnsureLabel() private Label EnsureLabel()
{ {
if (m_StatusLabel is not null) if (m_StatusLabel is not null)
@@ -39,5 +65,13 @@ public partial class DebugOverlay : CanvasLayer
return m_StatusLabel; return m_StatusLabel;
} }
private static string DisplayOrNone(string value)
{
return string.IsNullOrWhiteSpace(value) ? "none" : value;
}
private Label? m_StatusLabel; private Label? m_StatusLabel;
private DebugCommandService? m_Service;
private DebugSettings? m_Settings;
private string m_LoadedSceneId = "none";
} }

View File

@@ -0,0 +1,88 @@
#nullable enable
using Godot;
using SideScrollerGame.Debug.Commands;
namespace SideScrollerGame.Debug;
public partial class DebugPanelController : PanelContainer
{
public override void _Ready()
{
ProcessMode = ProcessModeEnum.Always;
EnsureLayout();
TryBindFromRoot();
}
public void Bind(DebugCommandNode commandNode)
{
m_CommandNode = commandNode;
}
private void EnsureLayout()
{
if (m_Container is not null)
{
return;
}
m_Container = new VBoxContainer { Name = "Controls" };
AddChild(m_Container);
AddHeader("Time");
AddButton("Pause", DebugCommandId.TogglePause);
AddButton("Step", DebugCommandId.FrameStep);
AddButton("0.25x", DebugCommandId.SetTimeScale, "0.25");
AddButton("0.5x", DebugCommandId.SetTimeScale, "0.5");
AddButton("1x", DebugCommandId.SetTimeScale, "1");
AddButton("2x", DebugCommandId.SetTimeScale, "2");
AddButton("4x", DebugCommandId.SetTimeScale, "4");
AddHeader("Content");
AddButton("Easy", DebugCommandId.SetDifficulty, "difficulty.easy");
AddButton("Normal", DebugCommandId.SetDifficulty, "difficulty.normal");
AddButton("Hard", DebugCommandId.SetDifficulty, "difficulty.hard");
AddButton("Spawn serial", DebugCommandId.SpawnActor, "enemy.serial");
AddButton("Spawn parallel", DebugCommandId.SpawnActor, "enemy.parallel");
AddButton("Intro", DebugCommandId.JumpToMarker, "intro");
AddButton("Opening", DebugCommandId.JumpToMarker, "cluster.opening");
AddHeader("Flags");
AddButton("Invulnerable", DebugCommandId.ToggleInvulnerability);
AddButton("Infinite ammo", DebugCommandId.ToggleInfiniteSpecialAmmo);
AddButton("No enemy fire", DebugCommandId.ToggleNoEnemyFire);
AddButton("Collisions", DebugCommandId.ToggleCollisionShapes);
AddButton("Bounds", DebugCommandId.ToggleGameplayBounds);
AddHeader("Scene");
AddButton("Restart", DebugCommandId.RestartMission);
AddButton("Reload", DebugCommandId.ReloadScene);
}
private void TryBindFromRoot()
{
m_CommandNode ??= GetNodeOrNull<DebugCommandNode>("/root/GameRoot/DebugCommandNode");
}
private void AddHeader(string text)
{
Label label = new() { Text = text };
m_Container?.AddChild(label);
}
private void AddButton(string text, DebugCommandId commandId, string? argument = null)
{
Button button = new() { Text = text };
button.Pressed += () => Execute(commandId, argument);
m_Container?.AddChild(button);
}
private void Execute(DebugCommandId commandId, string? argument = null)
{
TryBindFromRoot();
m_CommandNode?.Execute(commandId, argument);
}
private DebugCommandNode? m_CommandNode;
private VBoxContainer? m_Container;
}

View File

@@ -0,0 +1 @@
uid://cu534no72ttbm

View File

@@ -0,0 +1,284 @@
#nullable enable
using System;
using System.Globalization;
using System.Linq;
using System.Threading.Tasks;
using Godot;
using SideScrollerGame.Debug.Commands;
namespace SideScrollerGame.Debug;
public partial class DebugSandboxController : Control
{
public override void _Ready()
{
ProcessMode = ProcessModeEnum.Always;
m_CommandNode = GetNodeOrNull<DebugCommandNode>("/root/GameRoot/DebugCommandNode");
if (m_CommandNode is null)
{
GD.PushError("Debug sandbox needs /root/GameRoot/DebugCommandNode.");
return;
}
BindSceneNodes();
BindCommandService();
RefreshLabels();
if (ShouldRunFoundationSmoke())
{
_ = RunFoundationSmokeAsync();
}
}
public override void _UnhandledInput(InputEvent @event)
{
if (m_CommandNode is null)
{
return;
}
if (@event.IsActionPressed("debug_overlay"))
{
Execute(DebugCommandId.ToggleOverlay);
}
else if (@event.IsActionPressed("pause_game") || @event.IsActionPressed("debug_pause"))
{
Execute(DebugCommandId.TogglePause);
}
else if (@event.IsActionPressed("debug_frame_step"))
{
Execute(DebugCommandId.FrameStep);
}
else if (@event.IsActionPressed("debug_time_slower"))
{
Execute(DebugCommandId.SetTimeScale, NextTimeScale(-1));
}
else if (@event.IsActionPressed("debug_time_faster"))
{
Execute(DebugCommandId.SetTimeScale, NextTimeScale(1));
}
else if (@event.IsActionPressed("debug_spawn_actor"))
{
Execute(DebugCommandId.SpawnActor, "enemy.serial");
}
else if (@event.IsActionPressed("debug_jump_marker"))
{
Execute(DebugCommandId.JumpToMarker, "cluster.opening");
}
else if (@event.IsActionPressed("debug_toggle_invulnerability"))
{
Execute(DebugCommandId.ToggleInvulnerability);
}
else if (@event.IsActionPressed("debug_toggle_infinite_special_ammo"))
{
Execute(DebugCommandId.ToggleInfiniteSpecialAmmo);
}
else if (@event.IsActionPressed("debug_toggle_no_enemy_fire"))
{
Execute(DebugCommandId.ToggleNoEnemyFire);
}
else if (@event.IsActionPressed("debug_toggle_collision_shapes"))
{
Execute(DebugCommandId.ToggleCollisionShapes);
}
else if (@event.IsActionPressed("debug_toggle_gameplay_bounds"))
{
Execute(DebugCommandId.ToggleGameplayBounds);
}
else if (@event.IsActionPressed("quick_restart"))
{
Execute(DebugCommandId.RestartMission);
}
}
private void BindSceneNodes()
{
m_MarkerLabel = GetNodeOrNull<Label>("Main/Playfield/PlayfieldContent/MarkerLabel");
m_SpawnedActors = GetNodeOrNull<VBoxContainer>("Main/Playfield/PlayfieldContent/SpawnedActors");
m_LogLabel = GetNodeOrNull<Label>("Main/LogScroll/LogLabel");
m_StateLabel = GetNodeOrNull<Label>("Main/Playfield/PlayfieldContent/StateLabel");
DebugPanelController? panel = GetNodeOrNull<DebugPanelController>("Main/DebugPanel");
if (panel is not null && m_CommandNode is not null)
{
panel.Bind(m_CommandNode);
}
}
private void BindCommandService()
{
if (m_CommandNode is null)
{
return;
}
DebugCommandService service = m_CommandNode.Service;
service.RegisterSpawnHandler(SpawnActor);
service.RegisterTimelineJumpHandler(JumpToMarker);
service.RegisterRestartHandler(RestartSandbox);
service.RegisterReloadHandler(() => DebugCommandResult.Success(DebugCommandId.ReloadScene, "Reload scene requested"));
service.CommandExecuted += HandleCommandExecuted;
service.StateChanged += _ => RefreshLabels();
}
private DebugCommandResult SpawnActor(string actorId)
{
Label label = new()
{
Text = $"{m_SpawnedActors?.GetChildCount() + 1 ?? 1}: {actorId}",
CustomMinimumSize = new Vector2(180.0f, 24.0f)
};
m_SpawnedActors?.AddChild(label);
return DebugCommandResult.Success(DebugCommandId.SpawnActor, $"Actor spawned: {actorId}", actorId);
}
private DebugCommandResult JumpToMarker(string markerId)
{
if (m_MarkerLabel is not null)
{
m_MarkerLabel.Text = $"Marker: {markerId}";
}
return DebugCommandResult.Success(DebugCommandId.JumpToMarker, $"Timeline marker: {markerId}", markerId);
}
private DebugCommandResult RestartSandbox()
{
if (m_SpawnedActors is not null)
{
foreach (Node child in m_SpawnedActors.GetChildren())
{
child.QueueFree();
}
}
if (m_MarkerLabel is not null)
{
m_MarkerLabel.Text = "Marker: none";
}
return DebugCommandResult.Success(DebugCommandId.RestartMission, "Sandbox restarted");
}
private void HandleCommandExecuted(DebugCommandResult result)
{
string suffix = string.IsNullOrWhiteSpace(result.Argument) ? string.Empty : $" {result.Argument}";
AppendLog($"Command executed: {result.CommandId}{suffix}");
if (!result.Succeeded)
{
AppendLog($"Command failed: {result.Message}");
return;
}
if (result.CommandId is DebugCommandId.SpawnActor or DebugCommandId.JumpToMarker)
{
AppendLog(result.Message);
}
}
private void RefreshLabels()
{
if (m_CommandNode is null)
{
return;
}
DebugRuntimeState state = m_CommandNode.Service.State;
if (m_StateLabel is not null)
{
m_StateLabel.Text = $"Difficulty: {state.ActiveDifficultyId} | Seed: {state.Seed} | Paused: {state.IsPaused} | Time: {state.TimeScale.ToString(CultureInfo.InvariantCulture)} | Spawns: {state.SpawnedActorCount}";
}
if (m_MarkerLabel is not null && string.IsNullOrWhiteSpace(state.CurrentMarkerId))
{
m_MarkerLabel.Text = "Marker: none";
}
}
private void Execute(DebugCommandId commandId, string? argument = null)
{
m_CommandNode?.Execute(commandId, argument);
}
private string NextTimeScale(int direction)
{
if (m_CommandNode is null)
{
return "1";
}
double current = m_CommandNode.Service.State.TimeScale;
int index = Array.IndexOf(s_TimeScales, current);
if (index < 0)
{
index = Array.IndexOf(s_TimeScales, 1.0);
}
int nextIndex = Math.Clamp(index + direction, 0, s_TimeScales.Length - 1);
return s_TimeScales[nextIndex].ToString(CultureInfo.InvariantCulture);
}
private async Task RunFoundationSmokeAsync()
{
await ToSignal(GetTree(), SceneTree.SignalName.ProcessFrame);
AppendLog("Debug foundation smoke loaded");
bool succeeded = ExecuteAndRequire(DebugCommandId.Pause) && ExecuteAndRequire(DebugCommandId.SetTimeScale, "0.5") && ExecuteAndRequire(DebugCommandId.SetDifficulty, "difficulty.hard") && ExecuteAndRequire(DebugCommandId.SetSeed, m_CommandNode?.Service.State.Seed.ToString(CultureInfo.InvariantCulture)) && ExecuteAndRequire(DebugCommandId.SpawnActor, "enemy.serial") && ExecuteAndRequire(DebugCommandId.JumpToMarker, "cluster.opening") && ExecuteAndRequire(DebugCommandId.ToggleInvulnerability) && ExecuteAndRequire(DebugCommandId.ToggleInfiniteSpecialAmmo) && ExecuteAndRequire(DebugCommandId.ToggleNoEnemyFire) && ExecuteAndRequire(DebugCommandId.ToggleCollisionShapes) && ExecuteAndRequire(DebugCommandId.ToggleGameplayBounds) && ExecuteAndRequire(DebugCommandId.RestartMission) && ExecuteAndRequire(DebugCommandId.SpawnActor, "enemy.parallel") && VerifyFoundationSmokeState();
AppendLog(succeeded ? "Debug foundation smoke succeeded" : "Debug foundation smoke failed");
GetTree().Quit(succeeded ? 0 : 1);
}
private bool ExecuteAndRequire(DebugCommandId commandId, string? argument = null)
{
if (m_CommandNode is null)
{
return false;
}
DebugCommandResult result = m_CommandNode.Execute(commandId, argument);
if (result.Succeeded)
{
return true;
}
AppendLog(result.Message);
return false;
}
private bool VerifyFoundationSmokeState()
{
if (m_CommandNode is null)
{
return false;
}
DebugRuntimeState state = m_CommandNode.Service.State;
return state.IsPaused && Math.Abs(state.TimeScale - 0.5) < 0.001 && state.ActiveDifficultyId == "difficulty.hard" && state.LastSpawnedActorId == "enemy.parallel" && state.SpawnedActorCount == 1 && state.RestartMissionRequestCount == 1 && state.Invulnerable && state.InfiniteSpecialAmmo && state.NoEnemyFire && state.ShowCollisionShapes && state.ShowGameplayBounds;
}
private void AppendLog(string message)
{
GD.Print(message);
if (m_LogLabel is null)
{
return;
}
m_LogLabel.Text = string.IsNullOrWhiteSpace(m_LogLabel.Text) ? message : $"{m_LogLabel.Text}\n{message}";
}
private static bool ShouldRunFoundationSmoke()
{
return DisplayServer.GetName().Equals("headless", StringComparison.OrdinalIgnoreCase) && OS.GetCmdlineUserArgs().Any(argument => argument.Equals("--debug-script=foundation-smoke", StringComparison.OrdinalIgnoreCase));
}
private static readonly double[] s_TimeScales = [0.25, 0.5, 1.0, 2.0, 4.0];
private DebugCommandNode? m_CommandNode;
private Label? m_MarkerLabel;
private VBoxContainer? m_SpawnedActors;
private Label? m_LogLabel;
private Label? m_StateLabel;
}

View File

@@ -0,0 +1 @@
uid://dcfnx61rcgnr6

View File

@@ -0,0 +1,24 @@
#nullable enable
namespace SideScrollerGame.Debug.Commands;
public enum DebugCommandId
{
ToggleOverlay,
Pause,
Resume,
TogglePause,
FrameStep,
SetTimeScale,
ReloadScene,
RestartMission,
SetDifficulty,
SetSeed,
SpawnActor,
JumpToMarker,
ToggleCollisionShapes,
ToggleGameplayBounds,
ToggleInvulnerability,
ToggleInfiniteSpecialAmmo,
ToggleNoEnemyFire
}

View File

@@ -0,0 +1 @@
uid://ctqlux8vh57w8

View File

@@ -0,0 +1,16 @@
#nullable enable
namespace SideScrollerGame.Debug.Commands;
public sealed record DebugCommandResult(DebugCommandId CommandId, bool Succeeded, string Message, string? Argument = null)
{
public static DebugCommandResult Success(DebugCommandId commandId, string message, string? argument = null)
{
return new DebugCommandResult(commandId, true, message, argument);
}
public static DebugCommandResult Failure(DebugCommandId commandId, string message, string? argument = null)
{
return new DebugCommandResult(commandId, false, message, argument);
}
}

View File

@@ -0,0 +1 @@
uid://d1vtu2ro2libp

View File

@@ -0,0 +1,283 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using SideScrollerGame.Content;
namespace SideScrollerGame.Debug.Commands;
public sealed class DebugCommandService
{
public DebugCommandService(ContentRegistry registry, int seed)
{
m_Registry = registry;
State = new DebugRuntimeState(FindDefaultDifficultyId(registry), seed);
}
public DebugCommandResult Execute(DebugCommandId commandId, string? argument = null)
{
DebugCommandResult result = commandId switch
{
DebugCommandId.ToggleOverlay => ToggleOverlay(),
DebugCommandId.Pause => SetPaused(commandId, true),
DebugCommandId.Resume => SetPaused(commandId, false),
DebugCommandId.TogglePause => SetPaused(commandId, !State.IsPaused),
DebugCommandId.FrameStep => FrameStep(),
DebugCommandId.SetTimeScale => SetTimeScale(argument),
DebugCommandId.ReloadScene => ReloadScene(),
DebugCommandId.RestartMission => RestartMission(),
DebugCommandId.SetDifficulty => SetDifficulty(argument),
DebugCommandId.SetSeed => SetSeed(argument),
DebugCommandId.SpawnActor => SpawnActor(argument),
DebugCommandId.JumpToMarker => JumpToMarker(argument),
DebugCommandId.ToggleCollisionShapes => ToggleFlag(commandId, nameof(State.ShowCollisionShapes)),
DebugCommandId.ToggleGameplayBounds => ToggleFlag(commandId, nameof(State.ShowGameplayBounds)),
DebugCommandId.ToggleInvulnerability => ToggleFlag(commandId, nameof(State.Invulnerable)),
DebugCommandId.ToggleInfiniteSpecialAmmo => ToggleFlag(commandId, nameof(State.InfiniteSpecialAmmo)),
DebugCommandId.ToggleNoEnemyFire => ToggleFlag(commandId, nameof(State.NoEnemyFire)),
_ => DebugCommandResult.Failure(commandId, $"Unsupported debug command '{commandId}'.", argument)
};
CommandExecuted?.Invoke(result);
return result;
}
public void RegisterSpawnHandler(Func<string, DebugCommandResult> handler)
{
m_SpawnHandler = handler;
}
public void RegisterTimelineJumpHandler(Func<string, DebugCommandResult> handler)
{
m_TimelineJumpHandler = handler;
}
public void RegisterReloadHandler(Func<DebugCommandResult> handler)
{
m_ReloadHandler = handler;
}
public void RegisterRestartHandler(Func<DebugCommandResult> handler)
{
m_RestartHandler = handler;
}
public DebugRuntimeState State { get; }
public event Action<DebugRuntimeState>? StateChanged;
public event Action<DebugCommandResult>? CommandExecuted;
private DebugCommandResult ToggleOverlay()
{
State.OverlayVisible = !State.OverlayVisible;
NotifyStateChanged();
return DebugCommandResult.Success(DebugCommandId.ToggleOverlay, $"Overlay visible: {State.OverlayVisible}");
}
private DebugCommandResult SetPaused(DebugCommandId commandId, bool isPaused)
{
State.IsPaused = isPaused;
NotifyStateChanged();
return DebugCommandResult.Success(commandId, State.IsPaused ? "Paused" : "Resumed");
}
private DebugCommandResult FrameStep()
{
State.IsPaused = true;
State.FrameStepRequestCount++;
NotifyStateChanged();
return DebugCommandResult.Success(DebugCommandId.FrameStep, "Frame step requested");
}
private DebugCommandResult SetTimeScale(string? argument)
{
if (!double.TryParse(argument, NumberStyles.Float, CultureInfo.InvariantCulture, out double timeScale) || !s_SupportedTimeScales.Contains(timeScale))
{
return DebugCommandResult.Failure(DebugCommandId.SetTimeScale, $"Unsupported time scale '{argument}'.", argument);
}
State.TimeScale = timeScale;
NotifyStateChanged();
return DebugCommandResult.Success(DebugCommandId.SetTimeScale, $"Time scale set to {timeScale.ToString(CultureInfo.InvariantCulture)}", argument);
}
private DebugCommandResult ReloadScene()
{
State.ReloadSceneRequestCount++;
DebugCommandResult result = m_ReloadHandler?.Invoke() ?? DebugCommandResult.Success(DebugCommandId.ReloadScene, "Reload scene requested");
NotifyStateChanged();
return result.Succeeded ? DebugCommandResult.Success(DebugCommandId.ReloadScene, result.Message) : result;
}
private DebugCommandResult RestartMission()
{
State.RestartMissionRequestCount++;
State.CurrentMarkerId = string.Empty;
State.LastSpawnedActorId = string.Empty;
State.SpawnedActorCount = 0;
DebugCommandResult result = m_RestartHandler?.Invoke() ?? DebugCommandResult.Success(DebugCommandId.RestartMission, "Restart mission requested");
NotifyStateChanged();
return result.Succeeded ? DebugCommandResult.Success(DebugCommandId.RestartMission, result.Message) : result;
}
private DebugCommandResult SetDifficulty(string? argument)
{
string difficultyId = RequireArgument(argument);
if (difficultyId.Length == 0)
{
return DebugCommandResult.Failure(DebugCommandId.SetDifficulty, "Missing difficulty id.", argument);
}
if (!m_Registry.Difficulties.ContainsKey(difficultyId))
{
return DebugCommandResult.Failure(DebugCommandId.SetDifficulty, $"Unknown difficulty '{difficultyId}'.", difficultyId);
}
State.ActiveDifficultyId = difficultyId;
NotifyStateChanged();
return DebugCommandResult.Success(DebugCommandId.SetDifficulty, $"Difficulty set to {difficultyId}", difficultyId);
}
private DebugCommandResult SetSeed(string? argument)
{
if (!int.TryParse(argument, NumberStyles.Integer, CultureInfo.InvariantCulture, out int seed))
{
return DebugCommandResult.Failure(DebugCommandId.SetSeed, $"Invalid seed '{argument}'.", argument);
}
State.Seed = seed;
NotifyStateChanged();
return DebugCommandResult.Success(DebugCommandId.SetSeed, $"Seed set to {seed}", argument);
}
private DebugCommandResult SpawnActor(string? argument)
{
string actorId = RequireArgument(argument);
if (actorId.Length == 0)
{
return DebugCommandResult.Failure(DebugCommandId.SpawnActor, "Missing actor id.", argument);
}
if (!m_Registry.EnemyTypes.ContainsKey(actorId))
{
return DebugCommandResult.Failure(DebugCommandId.SpawnActor, $"Unknown actor '{actorId}'.", actorId);
}
DebugCommandResult result = m_SpawnHandler?.Invoke(actorId) ?? DebugCommandResult.Success(DebugCommandId.SpawnActor, $"Spawn actor requested: {actorId}", actorId);
if (!result.Succeeded)
{
return result;
}
State.LastSpawnedActorId = actorId;
State.SpawnedActorCount++;
NotifyStateChanged();
return DebugCommandResult.Success(DebugCommandId.SpawnActor, result.Message, actorId);
}
private DebugCommandResult JumpToMarker(string? argument)
{
string markerId = RequireArgument(argument);
if (markerId.Length == 0)
{
return DebugCommandResult.Failure(DebugCommandId.JumpToMarker, "Missing marker id.", argument);
}
if (!HasMissionMarker(markerId))
{
return DebugCommandResult.Failure(DebugCommandId.JumpToMarker, $"Unknown marker '{markerId}'.", markerId);
}
DebugCommandResult result = m_TimelineJumpHandler?.Invoke(markerId) ?? DebugCommandResult.Success(DebugCommandId.JumpToMarker, $"Jump marker requested: {markerId}", markerId);
if (!result.Succeeded)
{
return result;
}
State.CurrentMarkerId = markerId;
NotifyStateChanged();
return DebugCommandResult.Success(DebugCommandId.JumpToMarker, result.Message, markerId);
}
private DebugCommandResult ToggleFlag(DebugCommandId commandId, string propertyName)
{
string message = propertyName switch
{
nameof(State.ShowCollisionShapes) => ToggleShowCollisionShapes(),
nameof(State.ShowGameplayBounds) => ToggleShowGameplayBounds(),
nameof(State.Invulnerable) => ToggleInvulnerable(),
nameof(State.InfiniteSpecialAmmo) => ToggleInfiniteSpecialAmmo(),
nameof(State.NoEnemyFire) => ToggleNoEnemyFire(),
_ => $"Unsupported flag '{propertyName}'."
};
NotifyStateChanged();
return DebugCommandResult.Success(commandId, message);
}
private string ToggleShowCollisionShapes()
{
State.ShowCollisionShapes = !State.ShowCollisionShapes;
return $"Collision shapes: {State.ShowCollisionShapes}";
}
private string ToggleShowGameplayBounds()
{
State.ShowGameplayBounds = !State.ShowGameplayBounds;
return $"Gameplay bounds: {State.ShowGameplayBounds}";
}
private string ToggleInvulnerable()
{
State.Invulnerable = !State.Invulnerable;
return $"Invulnerable: {State.Invulnerable}";
}
private string ToggleInfiniteSpecialAmmo()
{
State.InfiniteSpecialAmmo = !State.InfiniteSpecialAmmo;
return $"Infinite special ammo: {State.InfiniteSpecialAmmo}";
}
private string ToggleNoEnemyFire()
{
State.NoEnemyFire = !State.NoEnemyFire;
return $"No enemy fire: {State.NoEnemyFire}";
}
private bool HasMissionMarker(string markerId)
{
return m_Registry.MissionDefinitions.Any(mission => mission.TimelineMarkers.Contains(markerId));
}
private void NotifyStateChanged()
{
StateChanged?.Invoke(State);
}
private static string RequireArgument(string? argument)
{
return string.IsNullOrWhiteSpace(argument) ? string.Empty : argument.Trim();
}
private static string FindDefaultDifficultyId(ContentRegistry registry)
{
if (registry.TryGetMission("mission.test", out var mission) && mission is not null && registry.Difficulties.ContainsKey(mission.DefaultDifficultyId))
{
return mission.DefaultDifficultyId;
}
return registry.DifficultyDefinitions.FirstOrDefault()?.Id ?? string.Empty;
}
private static readonly HashSet<double> s_SupportedTimeScales = [0.25, 0.5, 1.0, 2.0, 4.0];
private readonly ContentRegistry m_Registry;
private Func<string, DebugCommandResult>? m_SpawnHandler;
private Func<string, DebugCommandResult>? m_TimelineJumpHandler;
private Func<DebugCommandResult>? m_ReloadHandler;
private Func<DebugCommandResult>? m_RestartHandler;
}

View File

@@ -0,0 +1 @@
uid://bqt7rjgmg268u

View File

@@ -0,0 +1,44 @@
#nullable enable
namespace SideScrollerGame.Debug.Commands;
public sealed class DebugRuntimeState
{
public DebugRuntimeState(string activeDifficultyId, int seed)
{
ActiveDifficultyId = activeDifficultyId;
Seed = seed;
}
public bool OverlayVisible { get; internal set; } = true;
public bool IsPaused { get; internal set; }
public double TimeScale { get; internal set; } = 1.0;
public string ActiveDifficultyId { get; internal set; }
public int Seed { get; internal set; }
public string CurrentMarkerId { get; internal set; } = string.Empty;
public string LastSpawnedActorId { get; internal set; } = string.Empty;
public int SpawnedActorCount { get; internal set; }
public bool ShowCollisionShapes { get; internal set; }
public bool ShowGameplayBounds { get; internal set; }
public bool Invulnerable { get; internal set; }
public bool InfiniteSpecialAmmo { get; internal set; }
public bool NoEnemyFire { get; internal set; }
public int ReloadSceneRequestCount { get; internal set; }
public int RestartMissionRequestCount { get; internal set; }
public int FrameStepRequestCount { get; internal set; }
}

View File

@@ -0,0 +1 @@
uid://iaejn5w2r8q1

View File

@@ -0,0 +1,176 @@
#nullable enable
using SideScrollerGame.Content;
using SideScrollerGame.Content.Samples;
using SideScrollerGame.Debug.Commands;
namespace SideScrollerGame.Content.Tests;
public class DebugCommandServiceTests
{
[Fact]
public void PauseResumeAndTogglePause_UpdateState()
{
DebugCommandService service = CreateService();
service.Execute(DebugCommandId.Pause);
Assert.True(service.State.IsPaused);
service.Execute(DebugCommandId.Resume);
Assert.False(service.State.IsPaused);
service.Execute(DebugCommandId.TogglePause);
Assert.True(service.State.IsPaused);
}
[Theory]
[InlineData("0.25", 0.25)]
[InlineData("0.5", 0.5)]
[InlineData("1", 1.0)]
[InlineData("2", 2.0)]
[InlineData("4", 4.0)]
public void SetTimeScale_AcceptsSupportedValues(string argument, double expected)
{
DebugCommandService service = CreateService();
DebugCommandResult result = service.Execute(DebugCommandId.SetTimeScale, argument);
Assert.True(result.Succeeded);
Assert.Equal(expected, service.State.TimeScale);
}
[Fact]
public void SetTimeScale_RejectsUnsupportedValue()
{
DebugCommandService service = CreateService();
DebugCommandResult result = service.Execute(DebugCommandId.SetTimeScale, "3");
Assert.False(result.Succeeded);
Assert.Equal(1.0, service.State.TimeScale);
}
[Fact]
public void SetDifficulty_ValidatesIds()
{
DebugCommandService service = CreateService();
DebugCommandResult validResult = service.Execute(DebugCommandId.SetDifficulty, "difficulty.hard");
DebugCommandResult invalidResult = service.Execute(DebugCommandId.SetDifficulty, "difficulty.missing");
Assert.True(validResult.Succeeded);
Assert.Equal("difficulty.hard", service.State.ActiveDifficultyId);
Assert.False(invalidResult.Succeeded);
Assert.Equal("difficulty.hard", service.State.ActiveDifficultyId);
}
[Fact]
public void SetSeed_UpdatesState()
{
DebugCommandService service = CreateService();
DebugCommandResult result = service.Execute(DebugCommandId.SetSeed, "333");
Assert.True(result.Succeeded);
Assert.Equal(333, service.State.Seed);
}
[Fact]
public void ToggleFlags_UpdateState()
{
DebugCommandService service = CreateService();
service.Execute(DebugCommandId.ToggleCollisionShapes);
service.Execute(DebugCommandId.ToggleGameplayBounds);
service.Execute(DebugCommandId.ToggleInvulnerability);
service.Execute(DebugCommandId.ToggleInfiniteSpecialAmmo);
service.Execute(DebugCommandId.ToggleNoEnemyFire);
Assert.True(service.State.ShowCollisionShapes);
Assert.True(service.State.ShowGameplayBounds);
Assert.True(service.State.Invulnerable);
Assert.True(service.State.InfiniteSpecialAmmo);
Assert.True(service.State.NoEnemyFire);
}
[Fact]
public void SpawnActor_ValidatesActorAndRunsHandler()
{
DebugCommandService service = CreateService();
string spawnedActorId = string.Empty;
service.RegisterSpawnHandler(actorId =>
{
spawnedActorId = actorId;
return DebugCommandResult.Success(DebugCommandId.SpawnActor, $"Spawned {actorId}", actorId);
});
DebugCommandResult validResult = service.Execute(DebugCommandId.SpawnActor, "enemy.serial");
DebugCommandResult invalidResult = service.Execute(DebugCommandId.SpawnActor, "enemy.missing");
Assert.True(validResult.Succeeded);
Assert.Equal("enemy.serial", spawnedActorId);
Assert.Equal("enemy.serial", service.State.LastSpawnedActorId);
Assert.Equal(1, service.State.SpawnedActorCount);
Assert.False(invalidResult.Succeeded);
Assert.Equal(1, service.State.SpawnedActorCount);
}
[Fact]
public void JumpToMarker_ValidatesMarkerAndRunsHandler()
{
DebugCommandService service = CreateService();
string jumpedMarkerId = string.Empty;
service.RegisterTimelineJumpHandler(markerId =>
{
jumpedMarkerId = markerId;
return DebugCommandResult.Success(DebugCommandId.JumpToMarker, $"Jumped {markerId}", markerId);
});
DebugCommandResult validResult = service.Execute(DebugCommandId.JumpToMarker, "cluster.opening");
DebugCommandResult invalidResult = service.Execute(DebugCommandId.JumpToMarker, "cluster.missing");
Assert.True(validResult.Succeeded);
Assert.Equal("cluster.opening", jumpedMarkerId);
Assert.Equal("cluster.opening", service.State.CurrentMarkerId);
Assert.False(invalidResult.Succeeded);
Assert.Equal("cluster.opening", service.State.CurrentMarkerId);
}
[Fact]
public void RestartMission_IncrementsRequestCountAndClearsRuntimeTargets()
{
DebugCommandService service = CreateService();
service.Execute(DebugCommandId.SpawnActor, "enemy.serial");
service.Execute(DebugCommandId.JumpToMarker, "cluster.opening");
DebugCommandResult result = service.Execute(DebugCommandId.RestartMission);
Assert.True(result.Succeeded);
Assert.Equal(1, service.State.RestartMissionRequestCount);
Assert.Equal(0, service.State.SpawnedActorCount);
Assert.Equal(string.Empty, service.State.LastSpawnedActorId);
Assert.Equal(string.Empty, service.State.CurrentMarkerId);
}
[Fact]
public void CommandEvents_FireForExecutedCommands()
{
DebugCommandService service = CreateService();
int stateChangedCount = 0;
int commandExecutedCount = 0;
service.StateChanged += _ => stateChangedCount++;
service.CommandExecuted += _ => commandExecutedCount++;
service.Execute(DebugCommandId.Pause);
service.Execute(DebugCommandId.SetDifficulty, "difficulty.missing");
Assert.Equal(1, stateChangedCount);
Assert.Equal(2, commandExecutedCount);
}
private static DebugCommandService CreateService()
{
ContentRegistry registry = SampleContent.CreateRegistry();
return new DebugCommandService(registry, 123);
}
}