diff --git a/src/modules/bmgd/gametest/knowledge/godot-testing.md b/src/modules/bmgd/gametest/knowledge/godot-testing.md
index e282be22..ab79e093 100644
--- a/src/modules/bmgd/gametest/knowledge/godot-testing.md
+++ b/src/modules/bmgd/gametest/knowledge/godot-testing.md
@@ -374,3 +374,502 @@ test:
| Signal not detected | Signal not watched | Call `watch_signals()` before action |
| Physics not working | Missing frames | Await `physics_frame` |
| Flaky tests | Timing issues | Use proper await/signals |
+
+## C# Testing in Godot
+
+Godot 4 supports C# via .NET 6+. You can use standard .NET testing frameworks alongside GUT.
+
+### Project Setup for C#
+
+```
+project/
+├── addons/
+│ └── gut/
+├── src/
+│ ├── Player/
+│ │ └── PlayerController.cs
+│ └── Combat/
+│ └── DamageCalculator.cs
+├── tests/
+│ ├── gdscript/
+│ │ └── test_integration.gd
+│ └── csharp/
+│ ├── Tests.csproj
+│ └── DamageCalculatorTests.cs
+└── project.csproj
+```
+
+### C# Test Project Setup
+
+Create a separate test project that references your game assembly:
+
+```xml
+
+
+
+ net6.0
+ true
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+### Basic C# Unit Tests
+
+```csharp
+// tests/csharp/DamageCalculatorTests.cs
+using Xunit;
+using YourGame.Combat;
+
+public class DamageCalculatorTests
+{
+ private readonly DamageCalculator _calculator;
+
+ public DamageCalculatorTests()
+ {
+ _calculator = new DamageCalculator();
+ }
+
+ [Fact]
+ public void Calculate_BaseDamage_ReturnsCorrectValue()
+ {
+ var result = _calculator.Calculate(100f, 1f);
+ Assert.Equal(100f, result);
+ }
+
+ [Fact]
+ public void Calculate_CriticalHit_DoublesDamage()
+ {
+ var result = _calculator.Calculate(100f, 2f);
+ Assert.Equal(200f, result);
+ }
+
+ [Theory]
+ [InlineData(100f, 0.5f, 50f)]
+ [InlineData(100f, 1.5f, 150f)]
+ [InlineData(50f, 2f, 100f)]
+ public void Calculate_Parameterized_ReturnsExpected(
+ float baseDamage, float multiplier, float expected)
+ {
+ var result = _calculator.Calculate(baseDamage, multiplier);
+ Assert.Equal(expected, result);
+ }
+}
+```
+
+### Testing Godot Nodes in C#
+
+For tests requiring Godot runtime, use a hybrid approach:
+
+```csharp
+// tests/csharp/PlayerControllerTests.cs
+using Godot;
+using Xunit;
+using YourGame.Player;
+
+public class PlayerControllerTests : IDisposable
+{
+ private readonly SceneTree _sceneTree;
+ private PlayerController _player;
+
+ public PlayerControllerTests()
+ {
+ // These tests must run within Godot runtime
+ // Use GodotXUnit or similar adapter
+ }
+
+ [GodotFact] // Custom attribute for Godot runtime tests
+ public async Task Player_Move_ChangesPosition()
+ {
+ var startPos = _player.GlobalPosition;
+
+ _player.SetInput(new Vector2(1, 0));
+
+ await ToSignal(GetTree().CreateTimer(0.5f), "timeout");
+
+ Assert.True(_player.GlobalPosition.X > startPos.X);
+ }
+
+ public void Dispose()
+ {
+ _player?.QueueFree();
+ }
+}
+```
+
+### C# Mocking with NSubstitute
+
+```csharp
+using NSubstitute;
+using Xunit;
+
+public class EnemyAITests
+{
+ [Fact]
+ public void Enemy_UsesPathfinding_WhenMoving()
+ {
+ var mockPathfinding = Substitute.For();
+ mockPathfinding.FindPath(Arg.Any(), Arg.Any())
+ .Returns(new[] { Vector2.Zero, new Vector2(10, 10) });
+
+ var enemy = new EnemyAI(mockPathfinding);
+
+ enemy.MoveTo(new Vector2(10, 10));
+
+ mockPathfinding.Received().FindPath(
+ Arg.Any(),
+ Arg.Is(v => v == new Vector2(10, 10)));
+ }
+}
+```
+
+### Running C# Tests
+
+```bash
+# Run C# unit tests (no Godot runtime needed)
+dotnet test tests/csharp/Tests.csproj
+
+# Run with coverage
+dotnet test tests/csharp/Tests.csproj --collect:"XPlat Code Coverage"
+
+# Run specific test
+dotnet test tests/csharp/Tests.csproj --filter "FullyQualifiedName~DamageCalculator"
+```
+
+### Hybrid Test Strategy
+
+| Test Type | Framework | When to Use |
+| ------------- | ---------------- | ---------------------------------- |
+| Pure logic | xUnit/NUnit (C#) | Classes without Godot dependencies |
+| Node behavior | GUT (GDScript) | MonoBehaviour-like testing |
+| Integration | GUT (GDScript) | Scene and signal testing |
+| E2E | GUT (GDScript) | Full gameplay flows |
+
+## End-to-End Testing
+
+For comprehensive E2E testing patterns, infrastructure scaffolding, and
+scenario builders, see **knowledge/e2e-testing.md**.
+
+### E2E Infrastructure for Godot
+
+#### GameE2ETestFixture (GDScript)
+
+```gdscript
+# tests/e2e/infrastructure/game_e2e_test_fixture.gd
+extends GutTest
+class_name GameE2ETestFixture
+
+var game_state: GameStateManager
+var input_sim: InputSimulator
+var scenario: ScenarioBuilder
+var _scene_instance: Node
+
+## Override to specify a different scene for specific test classes.
+func get_scene_path() -> String:
+ return "res://scenes/game.tscn"
+
+func before_each():
+ # Load game scene
+ var scene = load(get_scene_path())
+ _scene_instance = scene.instantiate()
+ add_child(_scene_instance)
+
+ # Get references
+ game_state = _scene_instance.get_node("GameStateManager")
+ assert_not_null(game_state, "GameStateManager not found in scene")
+
+ input_sim = InputSimulator.new()
+ scenario = ScenarioBuilder.new(game_state)
+
+ # Wait for ready
+ await wait_for_game_ready()
+
+func after_each():
+ if _scene_instance:
+ _scene_instance.queue_free()
+ _scene_instance = null
+ input_sim = null
+ scenario = null
+
+func wait_for_game_ready(timeout: float = 10.0):
+ var elapsed = 0.0
+ while not game_state.is_ready and elapsed < timeout:
+ await get_tree().process_frame
+ elapsed += get_process_delta_time()
+ assert_true(game_state.is_ready, "Game should be ready within timeout")
+```
+
+#### ScenarioBuilder (GDScript)
+
+```gdscript
+# tests/e2e/infrastructure/scenario_builder.gd
+extends RefCounted
+class_name ScenarioBuilder
+
+var _game_state: GameStateManager
+var _setup_actions: Array[Callable] = []
+
+func _init(game_state: GameStateManager):
+ _game_state = game_state
+
+## Load a pre-configured scenario from a save file.
+func from_save_file(file_name: String) -> ScenarioBuilder:
+ _setup_actions.append(func(): await _load_save_file(file_name))
+ return self
+
+## Configure the current turn number.
+func on_turn(turn_number: int) -> ScenarioBuilder:
+ _setup_actions.append(func(): _set_turn(turn_number))
+ return self
+
+## Spawn a unit at position.
+func with_unit(faction: int, position: Vector2, movement_points: int = 6) -> ScenarioBuilder:
+ _setup_actions.append(func(): await _spawn_unit(faction, position, movement_points))
+ return self
+
+## Execute all configured setup actions.
+func build() -> void:
+ for action in _setup_actions:
+ await action.call()
+ _setup_actions.clear()
+
+## Clear pending actions without executing.
+func reset() -> void:
+ _setup_actions.clear()
+
+# Private implementation
+func _load_save_file(file_name: String) -> void:
+ var path = "res://tests/e2e/test_data/%s" % file_name
+ await _game_state.load_game(path)
+
+func _set_turn(turn: int) -> void:
+ _game_state.set_turn_number(turn)
+
+func _spawn_unit(faction: int, pos: Vector2, mp: int) -> void:
+ var unit = _game_state.spawn_unit(faction, pos)
+ unit.movement_points = mp
+```
+
+#### InputSimulator (GDScript)
+
+```gdscript
+# tests/e2e/infrastructure/input_simulator.gd
+extends RefCounted
+class_name InputSimulator
+
+## Click at a world position.
+func click_world_position(world_pos: Vector2) -> void:
+ var viewport = Engine.get_main_loop().root.get_viewport()
+ var camera = viewport.get_camera_2d()
+ var screen_pos = camera.get_screen_center_position() + (world_pos - camera.global_position)
+ await click_screen_position(screen_pos)
+
+## Click at a screen position.
+func click_screen_position(screen_pos: Vector2) -> void:
+ var press = InputEventMouseButton.new()
+ press.button_index = MOUSE_BUTTON_LEFT
+ press.pressed = true
+ press.position = screen_pos
+
+ var release = InputEventMouseButton.new()
+ release.button_index = MOUSE_BUTTON_LEFT
+ release.pressed = false
+ release.position = screen_pos
+
+ Input.parse_input_event(press)
+ await Engine.get_main_loop().process_frame
+ Input.parse_input_event(release)
+ await Engine.get_main_loop().process_frame
+
+## Click a UI button by name.
+func click_button(button_name: String) -> void:
+ var root = Engine.get_main_loop().root
+ var button = _find_button_recursive(root, button_name)
+ assert(button != null, "Button '%s' not found in scene tree" % button_name)
+
+ if not button.visible:
+ push_warning("[InputSimulator] Button '%s' is not visible" % button_name)
+ if button.disabled:
+ push_warning("[InputSimulator] Button '%s' is disabled" % button_name)
+
+ button.pressed.emit()
+ await Engine.get_main_loop().process_frame
+
+func _find_button_recursive(node: Node, button_name: String) -> Button:
+ if node is Button and node.name == button_name:
+ return node
+ for child in node.get_children():
+ var found = _find_button_recursive(child, button_name)
+ if found:
+ return found
+ return null
+
+## Press and release a key.
+func press_key(keycode: Key) -> void:
+ var press = InputEventKey.new()
+ press.keycode = keycode
+ press.pressed = true
+
+ var release = InputEventKey.new()
+ release.keycode = keycode
+ release.pressed = false
+
+ Input.parse_input_event(press)
+ await Engine.get_main_loop().process_frame
+ Input.parse_input_event(release)
+ await Engine.get_main_loop().process_frame
+
+## Simulate an input action.
+func action_press(action_name: String) -> void:
+ Input.action_press(action_name)
+ await Engine.get_main_loop().process_frame
+
+func action_release(action_name: String) -> void:
+ Input.action_release(action_name)
+ await Engine.get_main_loop().process_frame
+
+## Reset all input state.
+func reset() -> void:
+ Input.flush_buffered_events()
+```
+
+#### AsyncAssert (GDScript)
+
+```gdscript
+# tests/e2e/infrastructure/async_assert.gd
+extends RefCounted
+class_name AsyncAssert
+
+## Wait until condition is true, or fail after timeout.
+static func wait_until(
+ condition: Callable,
+ description: String,
+ timeout: float = 5.0
+) -> void:
+ var elapsed := 0.0
+ while not condition.call() and elapsed < timeout:
+ await Engine.get_main_loop().process_frame
+ elapsed += Engine.get_main_loop().root.get_process_delta_time()
+
+ assert(condition.call(),
+ "Timeout after %.1fs waiting for: %s" % [timeout, description])
+
+## Wait for a value to equal expected.
+static func wait_for_value(
+ getter: Callable,
+ expected: Variant,
+ description: String,
+ timeout: float = 5.0
+) -> void:
+ await wait_until(
+ func(): return getter.call() == expected,
+ "%s to equal '%s' (current: '%s')" % [description, expected, getter.call()],
+ timeout)
+
+## Wait for a float value within tolerance.
+static func wait_for_value_approx(
+ getter: Callable,
+ expected: float,
+ description: String,
+ tolerance: float = 0.0001,
+ timeout: float = 5.0
+) -> void:
+ await wait_until(
+ func(): return absf(expected - getter.call()) < tolerance,
+ "%s to equal ~%s ±%s (current: %s)" % [description, expected, tolerance, getter.call()],
+ timeout)
+
+## Assert that condition does NOT become true within duration.
+static func assert_never_true(
+ condition: Callable,
+ description: String,
+ duration: float = 1.0
+) -> void:
+ var elapsed := 0.0
+ while elapsed < duration:
+ assert(not condition.call(),
+ "Condition unexpectedly became true: %s" % description)
+ await Engine.get_main_loop().process_frame
+ elapsed += Engine.get_main_loop().root.get_process_delta_time()
+
+## Wait for specified number of frames.
+static func wait_frames(count: int) -> void:
+ for i in range(count):
+ await Engine.get_main_loop().process_frame
+
+## Wait for physics to settle.
+static func wait_for_physics(frames: int = 3) -> void:
+ for i in range(frames):
+ await Engine.get_main_loop().root.get_tree().physics_frame
+```
+
+### Example E2E Test (GDScript)
+
+```gdscript
+# tests/e2e/scenarios/test_combat_flow.gd
+extends GameE2ETestFixture
+
+func test_player_can_attack_enemy():
+ # GIVEN: Player and enemy in combat range
+ await scenario \
+ .with_unit(Faction.PLAYER, Vector2(100, 100)) \
+ .with_unit(Faction.ENEMY, Vector2(150, 100)) \
+ .build()
+
+ var enemy = game_state.get_units(Faction.ENEMY)[0]
+ var initial_health = enemy.health
+
+ # WHEN: Player attacks
+ await input_sim.click_world_position(Vector2(100, 100)) # Select player
+ await AsyncAssert.wait_until(
+ func(): return game_state.selected_unit != null,
+ "Unit should be selected")
+
+ await input_sim.click_world_position(Vector2(150, 100)) # Attack enemy
+
+ # THEN: Enemy takes damage
+ await AsyncAssert.wait_until(
+ func(): return enemy.health < initial_health,
+ "Enemy should take damage")
+
+func test_turn_cycle_completes():
+ # GIVEN: Game in progress
+ await scenario.on_turn(1).build()
+ var starting_turn = game_state.turn_number
+
+ # WHEN: Player ends turn
+ await input_sim.click_button("EndTurnButton")
+ await AsyncAssert.wait_until(
+ func(): return game_state.current_faction == Faction.ENEMY,
+ "Should switch to enemy turn")
+
+ # AND: Enemy turn completes
+ await AsyncAssert.wait_until(
+ func(): return game_state.current_faction == Faction.PLAYER,
+ "Should return to player turn",
+ 30.0) # AI might take a while
+
+ # THEN: Turn number incremented
+ assert_eq(game_state.turn_number, starting_turn + 1)
+```
+
+### Quick E2E Checklist for Godot
+
+- [ ] Create `GameE2ETestFixture` base class extending GutTest
+- [ ] Implement `ScenarioBuilder` for your game's domain
+- [ ] Create `InputSimulator` wrapping Godot Input
+- [ ] Add `AsyncAssert` utilities with proper await
+- [ ] Organize E2E tests under `tests/e2e/scenarios/`
+- [ ] Configure GUT to include E2E test directory
+- [ ] Set up CI with headless Godot execution
diff --git a/src/modules/bmgd/gametest/knowledge/unreal-testing.md b/src/modules/bmgd/gametest/knowledge/unreal-testing.md
index 0863bd0c..3b8f668d 100644
--- a/src/modules/bmgd/gametest/knowledge/unreal-testing.md
+++ b/src/modules/bmgd/gametest/knowledge/unreal-testing.md
@@ -386,3 +386,1129 @@ test:
| Crash in test | Missing world | Use proper test context |
| Flaky results | Timing issues | Use latent commands |
| Slow tests | Too many actors | Optimize test setup |
+
+## End-to-End Testing
+
+For comprehensive E2E testing patterns, infrastructure scaffolding, and
+scenario builders, see **knowledge/e2e-testing.md**.
+
+### E2E Infrastructure for Unreal
+
+E2E tests in Unreal leverage Functional Tests with custom infrastructure for scenario setup, input simulation, and async assertions.
+
+#### Project Structure
+
+```
+Source/
+├── MyGame/
+│ └── ... (game code)
+└── MyGameTests/
+ ├── MyGameTests.Build.cs
+ ├── Public/
+ │ ├── GameE2ETestBase.h
+ │ ├── ScenarioBuilder.h
+ │ ├── InputSimulator.h
+ │ └── AsyncTestHelpers.h
+ ├── Private/
+ │ ├── GameE2ETestBase.cpp
+ │ ├── ScenarioBuilder.cpp
+ │ ├── InputSimulator.cpp
+ │ ├── AsyncTestHelpers.cpp
+ │ └── E2E/
+ │ ├── CombatE2ETests.cpp
+ │ ├── TurnCycleE2ETests.cpp
+ │ └── SaveLoadE2ETests.cpp
+ └── TestMaps/
+ ├── E2E_Combat.umap
+ └── E2E_TurnCycle.umap
+```
+
+#### Test Module Build File
+
+```cpp
+// MyGameTests.Build.cs
+using UnrealBuildTool;
+
+public class MyGameTests : ModuleRules
+{
+ public MyGameTests(ReadOnlyTargetRules Target) : base(Target)
+ {
+ PCHUsage = ModuleRules.PCHUsageMode.UseExplicitOrSharedPCHs;
+
+ PublicDependencyModuleNames.AddRange(new string[] {
+ "Core",
+ "CoreUObject",
+ "Engine",
+ "InputCore",
+ "EnhancedInput",
+ "MyGame"
+ });
+
+ PrivateDependencyModuleNames.AddRange(new string[] {
+ "FunctionalTesting",
+ "AutomationController"
+ });
+
+ // Only include in editor/test builds
+ if (Target.bBuildDeveloperTools || Target.Configuration == UnrealTargetConfiguration.Debug)
+ {
+ PrecompileForTargets = PrecompileTargetsType.Any;
+ }
+ }
+}
+```
+
+#### GameE2ETestBase (Base Class)
+
+```cpp
+// GameE2ETestBase.h
+#pragma once
+
+#include "CoreMinimal.h"
+#include "FunctionalTest.h"
+#include "GameE2ETestBase.generated.h"
+
+class UScenarioBuilder;
+class UInputSimulator;
+class UGameStateManager;
+
+/**
+ * Base class for all E2E functional tests.
+ * Provides scenario setup, input simulation, and async assertion utilities.
+ */
+UCLASS(Abstract)
+class MYGAMETESTS_API AGameE2ETestBase : public AFunctionalTest
+{
+ GENERATED_BODY()
+
+public:
+ AGameE2ETestBase();
+
+protected:
+ /** Game state manager reference, found automatically on test start. */
+ UPROPERTY(BlueprintReadOnly, Category = "E2E")
+ UGameStateManager* GameState;
+
+ /** Input simulation utility. */
+ UPROPERTY(BlueprintReadOnly, Category = "E2E")
+ UInputSimulator* InputSim;
+
+ /** Scenario configuration builder. */
+ UPROPERTY(BlueprintReadOnly, Category = "E2E")
+ UScenarioBuilder* Scenario;
+
+ /** Timeout for waiting operations (seconds). */
+ UPROPERTY(EditAnywhere, Category = "E2E")
+ float DefaultTimeout = 10.0f;
+
+ // AFunctionalTest interface
+ virtual void PrepareTest() override;
+ virtual void StartTest() override;
+ virtual void CleanUp() override;
+
+ /** Override to specify custom game state class. */
+ virtual TSubclassOf GetGameStateClass() const;
+
+ /**
+ * Wait until game state reports ready.
+ * Calls OnGameReady() when complete or fails test on timeout.
+ */
+ UFUNCTION(BlueprintCallable, Category = "E2E")
+ void WaitForGameReady();
+
+ /** Called when game is ready. Override to begin test logic. */
+ virtual void OnGameReady();
+
+ /**
+ * Wait until condition is true, then call callback.
+ * Fails test if timeout exceeded.
+ */
+ void WaitUntil(TFunction Condition, const FString& Description,
+ TFunction OnComplete, float Timeout = -1.0f);
+
+ /**
+ * Wait for a specific value, then call callback.
+ */
+ template
+ void WaitForValue(TFunction Getter, T Expected,
+ const FString& Description, TFunction OnComplete,
+ float Timeout = -1.0f);
+
+ /**
+ * Assert condition and fail test with message if false.
+ */
+ void AssertTrue(bool Condition, const FString& Message);
+
+ /**
+ * Assert values are equal within tolerance.
+ */
+ void AssertNearlyEqual(float Actual, float Expected,
+ const FString& Message, float Tolerance = 0.0001f);
+
+private:
+ FTimerHandle WaitTimerHandle;
+ float WaitElapsed;
+ float WaitTimeout;
+ TFunction WaitCondition;
+ TFunction WaitCallback;
+ FString WaitDescription;
+
+ void TickWaitCondition();
+};
+```
+
+```cpp
+// GameE2ETestBase.cpp
+#include "GameE2ETestBase.h"
+#include "ScenarioBuilder.h"
+#include "InputSimulator.h"
+#include "GameStateManager.h"
+#include "Engine/World.h"
+#include "TimerManager.h"
+#include "Kismet/GameplayStatics.h"
+
+AGameE2ETestBase::AGameE2ETestBase()
+{
+ // Default test settings
+ TimeLimit = 120.0f; // 2 minute max for E2E tests
+ TimesUpMessage = TEXT("E2E test exceeded time limit");
+}
+
+void AGameE2ETestBase::PrepareTest()
+{
+ Super::PrepareTest();
+
+ // Create utilities
+ InputSim = NewObject(this);
+ Scenario = NewObject(this);
+}
+
+void AGameE2ETestBase::StartTest()
+{
+ Super::StartTest();
+
+ // Find game state manager
+ TSubclassOf GameStateClass = GetGameStateClass();
+ TArray FoundActors;
+ UGameplayStatics::GetAllActorsOfClass(GetWorld(), GameStateClass, FoundActors);
+
+ if (FoundActors.Num() > 0)
+ {
+ GameState = Cast(
+ FoundActors[0]->GetComponentByClass(GameStateClass));
+ }
+
+ if (!GameState)
+ {
+ FinishTest(EFunctionalTestResult::Failed,
+ FString::Printf(TEXT("GameStateManager not found in test world")));
+ return;
+ }
+
+ // Initialize scenario builder with game state
+ Scenario->Initialize(GameState);
+
+ // Wait for game to be ready
+ WaitForGameReady();
+}
+
+void AGameE2ETestBase::CleanUp()
+{
+ // Clear timer
+ if (WaitTimerHandle.IsValid())
+ {
+ GetWorld()->GetTimerManager().ClearTimer(WaitTimerHandle);
+ }
+
+ // Reset input state
+ if (InputSim)
+ {
+ InputSim->Reset();
+ }
+
+ Super::CleanUp();
+}
+
+TSubclassOf AGameE2ETestBase::GetGameStateClass() const
+{
+ return UGameStateManager::StaticClass();
+}
+
+void AGameE2ETestBase::WaitForGameReady()
+{
+ WaitUntil(
+ [this]() { return GameState && GameState->IsReady(); },
+ TEXT("Game to reach ready state"),
+ [this]() { OnGameReady(); },
+ DefaultTimeout
+ );
+}
+
+void AGameE2ETestBase::OnGameReady()
+{
+ // Override in derived classes to begin test logic
+}
+
+void AGameE2ETestBase::WaitUntil(
+ TFunction Condition,
+ const FString& Description,
+ TFunction OnComplete,
+ float Timeout)
+{
+ WaitCondition = Condition;
+ WaitCallback = OnComplete;
+ WaitDescription = Description;
+ WaitElapsed = 0.0f;
+ WaitTimeout = (Timeout < 0.0f) ? DefaultTimeout : Timeout;
+
+ // Check immediately
+ if (WaitCondition())
+ {
+ WaitCallback();
+ return;
+ }
+
+ // Set up polling timer
+ GetWorld()->GetTimerManager().SetTimer(
+ WaitTimerHandle,
+ this,
+ &AGameE2ETestBase::TickWaitCondition,
+ 0.1f, // Check every 100ms
+ true
+ );
+}
+
+void AGameE2ETestBase::TickWaitCondition()
+{
+ WaitElapsed += 0.1f;
+
+ if (WaitCondition())
+ {
+ GetWorld()->GetTimerManager().ClearTimer(WaitTimerHandle);
+ WaitCallback();
+ }
+ else if (WaitElapsed >= WaitTimeout)
+ {
+ GetWorld()->GetTimerManager().ClearTimer(WaitTimerHandle);
+ FinishTest(EFunctionalTestResult::Failed,
+ FString::Printf(TEXT("Timeout after %.1fs waiting for: %s"),
+ WaitTimeout, *WaitDescription));
+ }
+}
+
+void AGameE2ETestBase::AssertTrue(bool Condition, const FString& Message)
+{
+ if (!Condition)
+ {
+ FinishTest(EFunctionalTestResult::Failed, Message);
+ }
+}
+
+void AGameE2ETestBase::AssertNearlyEqual(
+ float Actual, float Expected,
+ const FString& Message, float Tolerance)
+{
+ if (!FMath::IsNearlyEqual(Actual, Expected, Tolerance))
+ {
+ FinishTest(EFunctionalTestResult::Failed,
+ FString::Printf(TEXT("%s: Expected ~%f, got %f"),
+ *Message, Expected, Actual));
+ }
+}
+```
+
+#### ScenarioBuilder
+
+```cpp
+// ScenarioBuilder.h
+#pragma once
+
+#include "CoreMinimal.h"
+#include "UObject/NoExportTypes.h"
+#include "ScenarioBuilder.generated.h"
+
+class UGameStateManager;
+
+/**
+ * Fluent API for configuring E2E test scenarios.
+ */
+UCLASS(BlueprintType)
+class MYGAMETESTS_API UScenarioBuilder : public UObject
+{
+ GENERATED_BODY()
+
+public:
+ /** Initialize with game state reference. */
+ void Initialize(UGameStateManager* InGameState);
+
+ /**
+ * Load scenario from save file.
+ * @param FileName Save file name (without path)
+ */
+ UFUNCTION(BlueprintCallable, Category = "Scenario")
+ UScenarioBuilder* FromSaveFile(const FString& FileName);
+
+ /**
+ * Set the current turn number.
+ */
+ UFUNCTION(BlueprintCallable, Category = "Scenario")
+ UScenarioBuilder* OnTurn(int32 TurnNumber);
+
+ /**
+ * Set the active faction.
+ */
+ UFUNCTION(BlueprintCallable, Category = "Scenario")
+ UScenarioBuilder* WithActiveFaction(EFaction Faction);
+
+ /**
+ * Spawn a unit at position.
+ * @param Faction Unit's faction
+ * @param Position World position
+ * @param MovementPoints Starting movement points
+ */
+ UFUNCTION(BlueprintCallable, Category = "Scenario")
+ UScenarioBuilder* WithUnit(EFaction Faction, FVector Position,
+ int32 MovementPoints = 6);
+
+ /**
+ * Set terrain at position.
+ */
+ UFUNCTION(BlueprintCallable, Category = "Scenario")
+ UScenarioBuilder* WithTerrain(FVector Position, ETerrainType Terrain);
+
+ /**
+ * Execute all queued setup actions.
+ * @param OnComplete Called when all actions complete
+ */
+ void Build(TFunction OnComplete);
+
+ /** Clear pending actions without executing. */
+ UFUNCTION(BlueprintCallable, Category = "Scenario")
+ void Reset();
+
+private:
+ UPROPERTY()
+ UGameStateManager* GameState;
+
+ TArray)>> SetupActions;
+
+ void ExecuteNextAction(int32 Index, TFunction FinalCallback);
+};
+```
+
+```cpp
+// ScenarioBuilder.cpp
+#include "ScenarioBuilder.h"
+#include "GameStateManager.h"
+
+void UScenarioBuilder::Initialize(UGameStateManager* InGameState)
+{
+ GameState = InGameState;
+ SetupActions.Empty();
+}
+
+UScenarioBuilder* UScenarioBuilder::FromSaveFile(const FString& FileName)
+{
+ SetupActions.Add([this, FileName](TFunction Done) {
+ FString Path = FString::Printf(TEXT("TestData/%s"), *FileName);
+ GameState->LoadGame(Path, FOnLoadComplete::CreateLambda([Done](bool bSuccess) {
+ Done();
+ }));
+ });
+ return this;
+}
+
+UScenarioBuilder* UScenarioBuilder::OnTurn(int32 TurnNumber)
+{
+ SetupActions.Add([this, TurnNumber](TFunction Done) {
+ GameState->SetTurnNumber(TurnNumber);
+ Done();
+ });
+ return this;
+}
+
+UScenarioBuilder* UScenarioBuilder::WithActiveFaction(EFaction Faction)
+{
+ SetupActions.Add([this, Faction](TFunction Done) {
+ GameState->SetActiveFaction(Faction);
+ Done();
+ });
+ return this;
+}
+
+UScenarioBuilder* UScenarioBuilder::WithUnit(
+ EFaction Faction, FVector Position, int32 MovementPoints)
+{
+ SetupActions.Add([this, Faction, Position, MovementPoints](TFunction Done) {
+ AUnit* Unit = GameState->SpawnUnit(Faction, Position);
+ if (Unit)
+ {
+ Unit->SetMovementPoints(MovementPoints);
+ }
+ Done();
+ });
+ return this;
+}
+
+UScenarioBuilder* UScenarioBuilder::WithTerrain(
+ FVector Position, ETerrainType Terrain)
+{
+ SetupActions.Add([this, Position, Terrain](TFunction Done) {
+ GameState->GetMap()->SetTerrain(Position, Terrain);
+ Done();
+ });
+ return this;
+}
+
+void UScenarioBuilder::Build(TFunction OnComplete)
+{
+ if (SetupActions.Num() == 0)
+ {
+ OnComplete();
+ return;
+ }
+
+ ExecuteNextAction(0, OnComplete);
+}
+
+void UScenarioBuilder::Reset()
+{
+ SetupActions.Empty();
+}
+
+void UScenarioBuilder::ExecuteNextAction(
+ int32 Index, TFunction FinalCallback)
+{
+ if (Index >= SetupActions.Num())
+ {
+ SetupActions.Empty();
+ FinalCallback();
+ return;
+ }
+
+ SetupActions[Index]([this, Index, FinalCallback]() {
+ ExecuteNextAction(Index + 1, FinalCallback);
+ });
+}
+```
+
+#### InputSimulator
+
+```cpp
+// InputSimulator.h
+#pragma once
+
+#include "CoreMinimal.h"
+#include "UObject/NoExportTypes.h"
+#include "InputCoreTypes.h"
+#include "InputSimulator.generated.h"
+
+class APlayerController;
+
+/**
+ * Simulates player input for E2E tests.
+ */
+UCLASS(BlueprintType)
+class MYGAMETESTS_API UInputSimulator : public UObject
+{
+ GENERATED_BODY()
+
+public:
+ /**
+ * Click at a world position.
+ * @param WorldPos Position in world space
+ * @param OnComplete Called when click completes
+ */
+ void ClickWorldPosition(FVector WorldPos, TFunction OnComplete);
+
+ /**
+ * Click at screen coordinates.
+ */
+ void ClickScreenPosition(FVector2D ScreenPos, TFunction OnComplete);
+
+ /**
+ * Click a UI button by name.
+ * @param ButtonName Name of the button widget
+ * @param OnComplete Called when click completes
+ */
+ UFUNCTION(BlueprintCallable, Category = "Input")
+ void ClickButton(const FString& ButtonName, TFunction OnComplete);
+
+ /**
+ * Press and release a key.
+ */
+ void PressKey(FKey Key, TFunction OnComplete);
+
+ /**
+ * Trigger an input action.
+ */
+ void TriggerAction(FName ActionName, TFunction OnComplete);
+
+ /**
+ * Drag from one position to another.
+ */
+ void DragFromTo(FVector From, FVector To, float Duration,
+ TFunction OnComplete);
+
+ /** Reset all input state. */
+ UFUNCTION(BlueprintCallable, Category = "Input")
+ void Reset();
+
+private:
+ APlayerController* GetPlayerController() const;
+ void SimulateMouseClick(FVector2D ScreenPos, TFunction OnComplete);
+};
+```
+
+```cpp
+// InputSimulator.cpp
+#include "InputSimulator.h"
+#include "GameFramework/PlayerController.h"
+#include "Blueprint/UserWidget.h"
+#include "Components/Button.h"
+#include "Blueprint/WidgetBlueprintLibrary.h"
+#include "Kismet/GameplayStatics.h"
+#include "Engine/World.h"
+#include "TimerManager.h"
+#include "Framework/Application/SlateApplication.h"
+
+void UInputSimulator::ClickWorldPosition(
+ FVector WorldPos, TFunction OnComplete)
+{
+ APlayerController* PC = GetPlayerController();
+ if (!PC)
+ {
+ OnComplete();
+ return;
+ }
+
+ FVector2D ScreenPos;
+ if (PC->ProjectWorldLocationToScreen(WorldPos, ScreenPos, true))
+ {
+ ClickScreenPosition(ScreenPos, OnComplete);
+ }
+ else
+ {
+ OnComplete();
+ }
+}
+
+void UInputSimulator::ClickScreenPosition(
+ FVector2D ScreenPos, TFunction OnComplete)
+{
+ SimulateMouseClick(ScreenPos, OnComplete);
+}
+
+void UInputSimulator::ClickButton(
+ const FString& ButtonName, TFunction OnComplete)
+{
+ APlayerController* PC = GetPlayerController();
+ if (!PC)
+ {
+ UE_LOG(LogTemp, Warning,
+ TEXT("[InputSimulator] No PlayerController found"));
+ OnComplete();
+ return;
+ }
+
+ // Find button in all widgets
+ TArray FoundWidgets;
+ UWidgetBlueprintLibrary::GetAllWidgetsOfClass(
+ PC->GetWorld(), FoundWidgets, UUserWidget::StaticClass(), false);
+
+ UButton* TargetButton = nullptr;
+ for (UUserWidget* Widget : FoundWidgets)
+ {
+ if (UButton* Button = Cast(
+ Widget->GetWidgetFromName(FName(*ButtonName))))
+ {
+ TargetButton = Button;
+ break;
+ }
+ }
+
+ if (TargetButton)
+ {
+ if (!TargetButton->GetIsEnabled())
+ {
+ UE_LOG(LogTemp, Warning,
+ TEXT("[InputSimulator] Button '%s' is not enabled"), *ButtonName);
+ }
+
+ // Simulate click via delegate
+ TargetButton->OnClicked.Broadcast();
+
+ // Delay to allow UI to process
+ FTimerHandle TimerHandle;
+ PC->GetWorld()->GetTimerManager().SetTimer(
+ TimerHandle,
+ [OnComplete]() { OnComplete(); },
+ 0.1f,
+ false
+ );
+ }
+ else
+ {
+ UE_LOG(LogTemp, Warning,
+ TEXT("[InputSimulator] Button '%s' not found"), *ButtonName);
+ OnComplete();
+ }
+}
+
+void UInputSimulator::PressKey(FKey Key, TFunction OnComplete)
+{
+ APlayerController* PC = GetPlayerController();
+ if (!PC)
+ {
+ OnComplete();
+ return;
+ }
+
+ // Simulate key press
+ FInputKeyEventArgs PressArgs(PC->GetLocalPlayer()->GetControllerId(),
+ Key, EInputEvent::IE_Pressed, 1.0f, false);
+ PC->InputKey(PressArgs);
+
+ // Delay then release
+ FTimerHandle TimerHandle;
+ PC->GetWorld()->GetTimerManager().SetTimer(
+ TimerHandle,
+ [this, PC, Key, OnComplete]() {
+ FInputKeyEventArgs ReleaseArgs(PC->GetLocalPlayer()->GetControllerId(),
+ Key, EInputEvent::IE_Released, 0.0f, false);
+ PC->InputKey(ReleaseArgs);
+ OnComplete();
+ },
+ 0.1f,
+ false
+ );
+}
+
+void UInputSimulator::TriggerAction(FName ActionName, TFunction OnComplete)
+{
+ APlayerController* PC = GetPlayerController();
+ if (!PC)
+ {
+ OnComplete();
+ return;
+ }
+
+ // For Enhanced Input System
+ if (UEnhancedInputComponent* EIC = Cast(
+ PC->InputComponent.Get()))
+ {
+ // Trigger the action through the input subsystem
+ // Implementation depends on your input action setup
+ }
+
+ OnComplete();
+}
+
+void UInputSimulator::DragFromTo(
+ FVector From, FVector To, float Duration, TFunction OnComplete)
+{
+ APlayerController* PC = GetPlayerController();
+ if (!PC)
+ {
+ OnComplete();
+ return;
+ }
+
+ FVector2D FromScreen, ToScreen;
+ PC->ProjectWorldLocationToScreen(From, FromScreen, true);
+ PC->ProjectWorldLocationToScreen(To, ToScreen, true);
+
+ // Simulate drag start
+ FSlateApplication::Get().ProcessMouseButtonDownEvent(
+ nullptr, FPointerEvent(
+ 0, FromScreen, FromScreen, TSet(),
+ EKeys::LeftMouseButton, 0, FModifierKeysState()
+ )
+ );
+
+ // Interpolate drag over duration
+ float Elapsed = 0.0f;
+ float Interval = 0.05f;
+
+ FTimerHandle DragTimer;
+ PC->GetWorld()->GetTimerManager().SetTimer(
+ DragTimer,
+ [this, PC, FromScreen, ToScreen, Duration, &Elapsed, Interval, OnComplete, &DragTimer]() {
+ Elapsed += Interval;
+ float Alpha = FMath::Clamp(Elapsed / Duration, 0.0f, 1.0f);
+ FVector2D CurrentPos = FMath::Lerp(FromScreen, ToScreen, Alpha);
+
+ FSlateApplication::Get().ProcessMouseMoveEvent(
+ FPointerEvent(
+ 0, CurrentPos, CurrentPos - FVector2D(1, 0),
+ TSet({EKeys::LeftMouseButton}),
+ FModifierKeysState()
+ )
+ );
+
+ if (Alpha >= 1.0f)
+ {
+ PC->GetWorld()->GetTimerManager().ClearTimer(DragTimer);
+
+ FSlateApplication::Get().ProcessMouseButtonUpEvent(
+ FPointerEvent(
+ 0, ToScreen, ToScreen, TSet(),
+ EKeys::LeftMouseButton, 0, FModifierKeysState()
+ )
+ );
+
+ OnComplete();
+ }
+ },
+ Interval,
+ true
+ );
+}
+
+void UInputSimulator::Reset()
+{
+ // Release any held inputs
+ FSlateApplication::Get().ClearAllUserFocus();
+}
+
+APlayerController* UInputSimulator::GetPlayerController() const
+{
+ UWorld* World = GEngine->GetWorldContexts()[0].World();
+ return World ? UGameplayStatics::GetPlayerController(World, 0) : nullptr;
+}
+
+void UInputSimulator::SimulateMouseClick(
+ FVector2D ScreenPos, TFunction OnComplete)
+{
+ // Press
+ FSlateApplication::Get().ProcessMouseButtonDownEvent(
+ nullptr, FPointerEvent(
+ 0, ScreenPos, ScreenPos, TSet(),
+ EKeys::LeftMouseButton, 0, FModifierKeysState()
+ )
+ );
+
+ // Delay then release
+ UWorld* World = GEngine->GetWorldContexts()[0].World();
+ if (World)
+ {
+ FTimerHandle TimerHandle;
+ World->GetTimerManager().SetTimer(
+ TimerHandle,
+ [ScreenPos, OnComplete]() {
+ FSlateApplication::Get().ProcessMouseButtonUpEvent(
+ FPointerEvent(
+ 0, ScreenPos, ScreenPos, TSet(),
+ EKeys::LeftMouseButton, 0, FModifierKeysState()
+ )
+ );
+ OnComplete();
+ },
+ 0.1f,
+ false
+ );
+ }
+ else
+ {
+ OnComplete();
+ }
+}
+```
+
+#### AsyncTestHelpers
+
+```cpp
+// AsyncTestHelpers.h
+#pragma once
+
+#include "CoreMinimal.h"
+#include "Misc/AutomationTest.h"
+
+/**
+ * Latent command to wait for a condition.
+ */
+DEFINE_LATENT_AUTOMATION_COMMAND_THREE_PARAMETER(
+ FWaitUntilCondition,
+ TFunction, Condition,
+ FString, Description,
+ float, Timeout
+);
+
+/**
+ * Latent command to wait for a value to equal expected.
+ */
+template
+class FWaitForValue : public IAutomationLatentCommand
+{
+public:
+ FWaitForValue(TFunction InGetter, T InExpected,
+ const FString& InDescription, float InTimeout)
+ : Getter(InGetter)
+ , Expected(InExpected)
+ , Description(InDescription)
+ , Timeout(InTimeout)
+ , Elapsed(0.0f)
+ {}
+
+ virtual bool Update() override
+ {
+ Elapsed += FApp::GetDeltaTime();
+
+ if (Getter() == Expected)
+ {
+ return true;
+ }
+
+ if (Elapsed >= Timeout)
+ {
+ UE_LOG(LogTemp, Error,
+ TEXT("Timeout after %.1fs waiting for: %s"),
+ Timeout, *Description);
+ return true;
+ }
+
+ return false;
+ }
+
+private:
+ TFunction Getter;
+ T Expected;
+ FString Description;
+ float Timeout;
+ float Elapsed;
+};
+
+/**
+ * Latent command to wait for float value within tolerance.
+ */
+class FWaitForValueApprox : public IAutomationLatentCommand
+{
+public:
+ FWaitForValueApprox(TFunction InGetter, float InExpected,
+ const FString& InDescription,
+ float InTolerance = 0.0001f, float InTimeout = 5.0f)
+ : Getter(InGetter)
+ , Expected(InExpected)
+ , Description(InDescription)
+ , Tolerance(InTolerance)
+ , Timeout(InTimeout)
+ , Elapsed(0.0f)
+ {}
+
+ virtual bool Update() override
+ {
+ Elapsed += FApp::GetDeltaTime();
+
+ if (FMath::IsNearlyEqual(Getter(), Expected, Tolerance))
+ {
+ return true;
+ }
+
+ if (Elapsed >= Timeout)
+ {
+ UE_LOG(LogTemp, Error,
+ TEXT("Timeout after %.1fs waiting for: %s (expected ~%f, got %f)"),
+ Timeout, *Description, Expected, Getter());
+ return true;
+ }
+
+ return false;
+ }
+
+private:
+ TFunction Getter;
+ float Expected;
+ FString Description;
+ float Tolerance;
+ float Timeout;
+ float Elapsed;
+};
+
+/**
+ * Latent command to assert condition never becomes true.
+ */
+DEFINE_LATENT_AUTOMATION_COMMAND_THREE_PARAMETER(
+ FAssertNeverTrue,
+ TFunction, Condition,
+ FString, Description,
+ float, Duration
+);
+
+/** Helper macros for E2E tests */
+#define E2E_WAIT_UNTIL(Cond, Desc, Timeout) \
+ ADD_LATENT_AUTOMATION_COMMAND(FWaitUntilCondition(Cond, Desc, Timeout))
+
+#define E2E_WAIT_FOR_VALUE(Getter, Expected, Desc, Timeout) \
+ ADD_LATENT_AUTOMATION_COMMAND(FWaitForValue(Getter, Expected, Desc, Timeout))
+
+#define E2E_WAIT_FOR_FLOAT(Getter, Expected, Desc, Tolerance, Timeout) \
+ ADD_LATENT_AUTOMATION_COMMAND(FWaitForValueApprox(Getter, Expected, Desc, Tolerance, Timeout))
+```
+
+### Example E2E Test
+
+```cpp
+// CombatE2ETests.cpp
+#include "GameE2ETestBase.h"
+#include "ScenarioBuilder.h"
+#include "InputSimulator.h"
+#include "AsyncTestHelpers.h"
+
+/**
+ * E2E test: Player can attack enemy and deal damage.
+ */
+UCLASS()
+class AE2E_PlayerAttacksEnemy : public AGameE2ETestBase
+{
+ GENERATED_BODY()
+
+protected:
+ virtual void OnGameReady() override
+ {
+ // GIVEN: Player and enemy units in combat range
+ Scenario
+ ->WithUnit(EFaction::Player, FVector(100, 100, 0), 6)
+ ->WithUnit(EFaction::Enemy, FVector(200, 100, 0), 6)
+ ->WithActiveFaction(EFaction::Player)
+ ->Build([this]() { OnScenarioReady(); });
+ }
+
+private:
+ void OnScenarioReady()
+ {
+ // Store enemy reference and initial health
+ TArray Enemies = GameState->GetUnits(EFaction::Enemy);
+ if (Enemies.Num() == 0)
+ {
+ FinishTest(EFunctionalTestResult::Failed, TEXT("No enemy found"));
+ return;
+ }
+
+ AUnit* Enemy = Enemies[0];
+ float InitialHealth = Enemy->GetHealth();
+
+ // WHEN: Player selects unit and attacks
+ InputSim->ClickWorldPosition(FVector(100, 100, 0), [this]() {
+ WaitUntil(
+ [this]() { return GameState->GetSelectedUnit() != nullptr; },
+ TEXT("Unit should be selected"),
+ [this, Enemy, InitialHealth]() { PerformAttack(Enemy, InitialHealth); }
+ );
+ });
+ }
+
+ void PerformAttack(AUnit* Enemy, float InitialHealth)
+ {
+ // Click on enemy to attack
+ InputSim->ClickWorldPosition(Enemy->GetActorLocation(), [this, Enemy, InitialHealth]() {
+ // THEN: Enemy takes damage
+ WaitUntil(
+ [Enemy, InitialHealth]() { return Enemy->GetHealth() < InitialHealth; },
+ TEXT("Enemy should take damage"),
+ [this]() {
+ FinishTest(EFunctionalTestResult::Succeeded,
+ TEXT("Player successfully attacked enemy"));
+ }
+ );
+ });
+ }
+};
+
+/**
+ * E2E test: Full turn cycle completes correctly.
+ */
+UCLASS()
+class AE2E_TurnCycleCompletes : public AGameE2ETestBase
+{
+ GENERATED_BODY()
+
+protected:
+ int32 StartingTurn;
+
+ virtual void OnGameReady() override
+ {
+ // GIVEN: Game in progress
+ Scenario
+ ->OnTurn(1)
+ ->WithActiveFaction(EFaction::Player)
+ ->Build([this]() { OnScenarioReady(); });
+ }
+
+private:
+ void OnScenarioReady()
+ {
+ StartingTurn = GameState->GetTurnNumber();
+
+ // WHEN: Player ends turn
+ InputSim->ClickButton(TEXT("EndTurnButton"), [this]() {
+ WaitUntil(
+ [this]() {
+ return GameState->GetActiveFaction() == EFaction::Enemy;
+ },
+ TEXT("Should switch to enemy turn"),
+ [this]() { WaitForPlayerTurnReturn(); }
+ );
+ });
+ }
+
+ void WaitForPlayerTurnReturn()
+ {
+ // Wait for AI turn to complete
+ WaitUntil(
+ [this]() {
+ return GameState->GetActiveFaction() == EFaction::Player;
+ },
+ TEXT("Should return to player turn"),
+ [this]() { VerifyTurnIncremented(); },
+ 30.0f // AI might take a while
+ );
+ }
+
+ void VerifyTurnIncremented()
+ {
+ // THEN: Turn number incremented
+ int32 CurrentTurn = GameState->GetTurnNumber();
+ if (CurrentTurn == StartingTurn + 1)
+ {
+ FinishTest(EFunctionalTestResult::Succeeded,
+ TEXT("Turn cycle completed successfully"));
+ }
+ else
+ {
+ FinishTest(EFunctionalTestResult::Failed,
+ FString::Printf(TEXT("Expected turn %d, got %d"),
+ StartingTurn + 1, CurrentTurn));
+ }
+ }
+};
+```
+
+### Running E2E Tests
+
+```bash
+# Run all E2E tests
+UnrealEditor-Cmd.exe MyGame.uproject \
+ -ExecCmds="Automation RunTests MyGame.E2E" \
+ -unattended -nopause -nullrhi
+
+# Run specific E2E test
+UnrealEditor-Cmd.exe MyGame.uproject \
+ -ExecCmds="Automation RunTests MyGame.E2E.Combat.PlayerAttacksEnemy" \
+ -unattended -nopause
+
+# Run with detailed logging
+UnrealEditor-Cmd.exe MyGame.uproject \
+ -ExecCmds="Automation RunTests MyGame.E2E" \
+ -unattended -nopause -log=E2ETests.log
+```
+
+### Quick E2E Checklist for Unreal
+
+- [ ] Create `GameE2ETestBase` class extending `AFunctionalTest`
+- [ ] Implement `ScenarioBuilder` for your game's domain
+- [ ] Create `InputSimulator` wrapping Slate input system
+- [ ] Add `AsyncTestHelpers` with latent commands
+- [ ] Create dedicated E2E test maps with spawn points
+- [ ] Organize E2E tests under `Source/MyGameTests/Private/E2E/`
+- [ ] Configure separate CI job for E2E suite with extended timeout
+- [ ] Use Gauntlet for extended E2E scenarios if needed