diff --git a/.github/workflows/unity-tests.yml b/.github/workflows/unity-tests.yml new file mode 100644 index 0000000..aa7fa47 --- /dev/null +++ b/.github/workflows/unity-tests.yml @@ -0,0 +1,72 @@ +name: Unity Tests + +on: + push: + branches: [ main, develop, feature/** ] + pull_request: + branches: [ main, develop ] + +jobs: + test: + name: Run Unity Tests + runs-on: self-hosted + + steps: + # Checkout repository + - name: Checkout repository + uses: actions/checkout@v4 + with: + lfs: true + + # Run EditMode tests using local Unity installation + - name: Run EditMode Tests + run: | + & "C:\Program Files\Unity\Hub\Editor\6000.3.2f1\Editor\Unity.exe" ` + -batchmode ` + -nographics ` + -silent-crashes ` + -logFile - ` + -projectPath "${{ github.workspace }}" ` + -runTests ` + -testPlatform EditMode ` + -testResults "${{ github.workspace }}\test-results\editmode\results.xml" + shell: powershell + + # Run PlayMode tests using local Unity installation + - name: Run PlayMode Tests + run: | + & "C:\Program Files\Unity\Hub\Editor\6000.3.2f1\Editor\Unity.exe" ` + -batchmode ` + -nographics ` + -silent-crashes ` + -logFile - ` + -projectPath "${{ github.workspace }}" ` + -runTests ` + -testPlatform PlayMode ` + -testResults "${{ github.workspace }}\test-results\playmode\results.xml" + shell: powershell + + # Upload test results as artifacts + - name: Upload EditMode Test Results + if: always() + uses: actions/upload-artifact@v4 + with: + name: EditMode-Test-Results + path: test-results/editmode + + - name: Upload PlayMode Test Results + if: always() + uses: actions/upload-artifact@v4 + with: + name: PlayMode-Test-Results + path: test-results/playmode + + # Report test results in PR + - name: Comment Test Results on PR + if: github.event_name == 'pull_request' && always() + uses: dorny/test-reporter@v1 + with: + name: Unity Test Results + path: 'test-results/**/*.xml' + reporter: java-junit + fail-on-error: true diff --git a/Assets/Scripts/Core/RingBuffer.cs b/Assets/Scripts/Core/RingBuffer.cs index d28a0ba..c79cecd 100644 --- a/Assets/Scripts/Core/RingBuffer.cs +++ b/Assets/Scripts/Core/RingBuffer.cs @@ -54,6 +54,11 @@ public bool Contains(uint tick) return slot.isValid && slot.tick == tick; } + /// + /// Expose capacity for validation and bounds checks. + /// + public uint Capacity => _capacity; + /// /// Invalidate range of ticks (inclusive). /// Used during hard snap to clear diverged speculative data. diff --git a/Assets/Scripts/Entities/ClientEntity.cs b/Assets/Scripts/Entities/ClientEntity.cs new file mode 100644 index 0000000..e397664 --- /dev/null +++ b/Assets/Scripts/Entities/ClientEntity.cs @@ -0,0 +1,170 @@ +using System; +using UnityEngine; +using DeterministicRollback.Core; +using DeterministicRollback.Networking; + +namespace DeterministicRollback.Entities +{ + /// + /// Non-Mono-client simulation core. Designed to be testable in EditMode (constructed with `new`). + /// Runs a fixed-step simulation at 60Hz using a time accumulator pattern. + /// + /// + /// Testable client-side simulation core. Runs a fixed-step 60Hz simulation using an accumulator. + /// Handles input capture, batch sending, deterministic integration and StateBuffer writes. + /// + public class ClientEntity + { + public const int MAX_TICKS_PER_FRAME = 10; + public const float FIXED_DELTA_TIME = 1f / 60f; + + // Tick counter: number of ticks simulated so far. Starts at 0 (spawn state at tick 0). + public uint CurrentTick { get; private set; } + + // Buffers (pre-allocated ring buffers) + public RingBuffer InputBuffer { get; private set; } + public RingBuffer StateBuffer { get; private set; } + + // Batch container reused every tick (value-type snapshot when sent) + private InputBatch _batchContainer; + + private float _timer = 0f; + public int ticksProcessed = 0; + + // Network parameters (configurable from wrapper or tests) + public float latencyMs = 0f; + public float lossChance = 0f; // 0.0 .. 1.0 + + // Input provider (in tests or wrapper set to supply input vector): returns Vector2 for current tick + public Func InputProvider = () => Vector2.zero; + + public ClientEntity() + { + Initialize(); + } + + /// + /// Initialize buffers and spawn state. Safe to call multiple times to reset client state. + /// + public void Initialize() + { + InputBuffer = new RingBuffer(4096); + StateBuffer = new RingBuffer(4096); + + var spawnState = new StatePayload + { + tick = 0, + position = Vector2.zero, + velocity = Vector2.zero, + confirmedInputTick = 0 + }; + + StateBuffer[0] = spawnState; + CurrentTick = 0; // nothing simulated yet; tick 0 is spawn + _batchContainer = new InputBatch(); + _timer = 0f; + ticksProcessed = 0; + } + + /// + /// Main update to be called every frame. Accumulates Time.deltaTime and simulates fixed ticks. + /// Designed to be called from both tests (EditMode) and a MonoBehaviour wrapper in PlayMode. + /// + /// + /// Advance simulation by consuming accumulated time (call every frame). + /// Runs up to ticks to prevent spiral-of-death. + /// This method is a hot path and must not allocate GC memory per tick. + /// + /// + /// Advance simulation by consuming accumulated time (call every frame). + /// Runs up to ticks to prevent spiral-of-death. + /// This method is a hot path and must not allocate GC memory per tick. + /// + public void Update() + { + UpdateWithDelta(Time.deltaTime); + } + + /// + /// Advance simulation using an explicit deltaTime. Useful for deterministic EditMode tests. + /// + public void UpdateWithDelta(float deltaTime) + { + _timer += deltaTime; + + // Compute available whole ticks deterministically using floor on double to avoid + // round-off errors that can drop one tick in edge cases. Use a small absolute + // epsilon large enough to cover float division underflow without affecting + // normal fractional behavior. + const double EPS = 1e-6; // 1 microsecond + int availableTicks = (int)Math.Floor(((double)_timer + EPS) / FIXED_DELTA_TIME); + int toProcess = Math.Min(availableTicks, MAX_TICKS_PER_FRAME); + + ticksProcessed = 0; + + for (int i = 0; i < toProcess; i++) + { + uint nextTick = CurrentTick + 1; // tick to simulate + + // Capture input + var input = new InputPayload + { + tick = nextTick, + inputVector = InputProvider() + }; + + // Always write input for this tick (even if zero) + InputBuffer[nextTick] = input; + + // Build redundant batch (1-3 inputs) + int batchSize = (int)Math.Min(3, (float)nextTick); + _batchContainer.count = batchSize; + if (batchSize >= 1) _batchContainer.i0 = InputBuffer[nextTick]; + if (batchSize >= 2) _batchContainer.i1 = InputBuffer[nextTick - 1]; + if (batchSize >= 3) _batchContainer.i2 = InputBuffer[nextTick - 2]; + + // Send batch (copied by value into list - zero allocations here) + FakeNetworkPipe.SendInput(_batchContainer, latencyMs, lossChance); + + // Retrieve previous state (tick 0 for first simulation) + StatePayload prev = StateBuffer[nextTick - 1]; + + // Integrate - deterministic, pure function + SimulationMath.Integrate(ref prev, ref input); + + // Store post-integration state + StateBuffer[nextTick] = prev; + + // Advance + CurrentTick = nextTick; + ticksProcessed++; + } + + // Subtract the total processed time in one operation to avoid cumulative float + // subtract-rounding artifacts and preserve residual accumulator precisely. + _timer -= toProcess * FIXED_DELTA_TIME; + + // If residual is extremely close to zero (due to float rounding), snap to zero so + // the next floor calculation does not undercount by one tick. + if (_timer < (float)EPS) _timer = 0f; + + if (availableTicks > MAX_TICKS_PER_FRAME) + { + Debug.LogWarning($"Client simulation spiral: clamped to {MAX_TICKS_PER_FRAME} ticks. Backlog remaining: {_timer:F3}s"); + } + } + + /// + /// Helper to access a stored state for assertions in tests. + /// Returns default StatePayload if not present. + /// + /// + /// Retrieve recorded post-integration StatePayload for a given tick. + /// Returns default(StatePayload) if history does not contain that tick. + /// + public StatePayload GetState(uint tick) + { + return StateBuffer.Contains(tick) ? StateBuffer[tick] : default; + } + } +} \ No newline at end of file diff --git a/Assets/Scripts/Entities/ClientEntity.cs.meta b/Assets/Scripts/Entities/ClientEntity.cs.meta new file mode 100644 index 0000000..a4abc36 --- /dev/null +++ b/Assets/Scripts/Entities/ClientEntity.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 678de3980a77a744f91577c4d6c43d8d \ No newline at end of file diff --git a/Assets/Scripts/Entities/ClientEntityBehaviour.cs b/Assets/Scripts/Entities/ClientEntityBehaviour.cs new file mode 100644 index 0000000..6f51ec6 --- /dev/null +++ b/Assets/Scripts/Entities/ClientEntityBehaviour.cs @@ -0,0 +1,61 @@ +using UnityEngine; +using DeterministicRollback.Entities; + +namespace DeterministicRollback.Behaviours +{ + /// + /// MonoBehaviour wrapper for ClientEntity. Supplies input and mirrors inspector-configurable network parameters. + /// Provides deterministic Auto-Move mode for tests and demo scenarios. + /// + public class ClientEntityBehaviour : MonoBehaviour + { + /// + /// One-way latency in milliseconds used for FakeNetworkPipe.SendInput() + /// + public float latencyMs = 0f; + [Range(0f, 1f)] public float lossChance = 0f; + public bool autoMove = false; + private ClientEntity _client; + + /// + /// Initialize the underlying ClientEntity and set up input provider. + /// + void Start() + { + _client = new ClientEntity(); + _client.latencyMs = latencyMs; + _client.lossChance = lossChance; + _client.InputProvider = ReadInput; + } + + /// + /// Forward Unity Update() to the testable ClientEntity core. + /// Keeps runtime-configurable parameters in sync. + /// + void Update() + { + // Mirror inspector values if changed at runtime + _client.latencyMs = latencyMs; + _client.lossChance = lossChance; + _client.Update(); + } + + private Vector2 ReadInput() + { + if (autoMove) + { + // Deterministic Auto-Move based on current tick + float time = _client.CurrentTick * ClientEntity.FIXED_DELTA_TIME; + float angle = 36f * time * Mathf.Deg2Rad; + return new Vector2(Mathf.Cos(angle), Mathf.Sin(angle)); + } + + Vector2 v = Vector2.zero; + if (UnityEngine.Input.GetKey(KeyCode.W)) v.y += 1f; + if (UnityEngine.Input.GetKey(KeyCode.S)) v.y -= 1f; + if (UnityEngine.Input.GetKey(KeyCode.A)) v.x -= 1f; + if (UnityEngine.Input.GetKey(KeyCode.D)) v.x += 1f; + return v; + } + } +} \ No newline at end of file diff --git a/Assets/Scripts/Entities/ClientEntityBehaviour.cs.meta b/Assets/Scripts/Entities/ClientEntityBehaviour.cs.meta new file mode 100644 index 0000000..9e10fdd --- /dev/null +++ b/Assets/Scripts/Entities/ClientEntityBehaviour.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e4a7ac0de8a19e5429d1c687d9e33b82 \ No newline at end of file diff --git a/Assets/Scripts/Entities/ServerEntity.cs b/Assets/Scripts/Entities/ServerEntity.cs new file mode 100644 index 0000000..e6b3179 --- /dev/null +++ b/Assets/Scripts/Entities/ServerEntity.cs @@ -0,0 +1,141 @@ +using System; +using UnityEngine; +using DeterministicRollback.Core; +using DeterministicRollback.Networking; + +namespace DeterministicRollback.Entities +{ + /// + /// Authoritative server simulation core. Runs a fixed-step simulation at 60Hz using an accumulator. + /// Testable in EditMode by calling UpdateWithDelta(float). + /// + public class ServerEntity : MonoBehaviour + { + public const int MAX_TICKS_PER_FRAME = 10; + public const float FIXED_DELTA_TIME = 1f / 60f; + + public uint ServerTick { get; private set; } + + public RingBuffer ServerInputBuffer { get; private set; } + public StatePayload CurrentState { get; private set; } + + private float _timer = 0f; + public int ticksProcessed = 0; + + public uint lastConfirmedInputTick = 0; + + // Network parameters (configurable from wrapper or tests) + public float latencyMs = 0f; + public float lossChance = 0f; // 0.0 .. 1.0 + + void Awake() + { + Initialize(); + } + + public void Initialize() + { + ServerInputBuffer = new RingBuffer(4096); + CurrentState = new StatePayload { tick = 0, position = Vector2.zero, velocity = Vector2.zero, confirmedInputTick = 0 }; + ServerTick = 1; + lastConfirmedInputTick = 0; + + // Ensure subscription is active for tests that call ProcessPackets immediately + FakeNetworkPipe.OnInputBatchReceived -= OnInputBatchReceived; + FakeNetworkPipe.OnInputBatchReceived += OnInputBatchReceived; + } + + void OnEnable() + { + FakeNetworkPipe.OnInputBatchReceived += OnInputBatchReceived; + } + + void OnDisable() + { + FakeNetworkPipe.OnInputBatchReceived -= OnInputBatchReceived; + } + + private void OnInputBatchReceived(InputBatch batch) + { + for (int i = 0; i < batch.count; i++) + { + var input = batch.Get(i); + // Validate tick range and avoid overwriting newer data + if (input.tick >= ServerTick && input.tick < ServerTick + (uint)ServerInputBuffer.Capacity) + { + if (!ServerInputBuffer.Contains(input.tick)) + { + ServerInputBuffer[input.tick] = input; + } + lastConfirmedInputTick = Math.Max(lastConfirmedInputTick, input.tick); + } + } + } + + void Update() + { + UpdateWithDelta(Time.deltaTime); + } + + public void UpdateWithDelta(float deltaTime) + { + _timer += deltaTime; + + const double EPS = 1e-6; + int availableTicks = (int)Math.Floor(((double)_timer + EPS) / FIXED_DELTA_TIME); + int toProcess = Math.Min(availableTicks, MAX_TICKS_PER_FRAME); + + ticksProcessed = 0; + + for (int i = 0; i < toProcess; i++) + { + // Retrieve input for this tick or predict + InputPayload input; + if (ServerInputBuffer.Contains(ServerTick)) + { + input = ServerInputBuffer[ServerTick]; + } + else if (ServerInputBuffer.Contains(ServerTick - 1)) + { + input = ServerInputBuffer[ServerTick - 1]; // repeat last + input.tick = ServerTick; // ensure tick matches current simulation + } + else + { + input = new InputPayload { tick = ServerTick, inputVector = Vector2.zero }; + } + + // Integrate + var prev = CurrentState; + SimulationMath.Integrate(ref prev, ref input); + + // Update current state and piggyback confirmation + prev.confirmedInputTick = lastConfirmedInputTick; + CurrentState = prev; + + // Send state (unreliable) + FakeNetworkPipe.SendState(CurrentState, latencyMs, lossChance); + + // Advance + ServerTick++; + ticksProcessed++; + } + + // Subtract processed time + _timer -= toProcess * FIXED_DELTA_TIME; + + if (ticksProcessed >= MAX_TICKS_PER_FRAME) + { + Debug.LogWarning($"Server simulation spiral: clamped to {MAX_TICKS_PER_FRAME} ticks. Backlog remaining: {_timer:F3}s"); + } + } + + public WelcomePacket GenerateWelcomePacket() + { + return new WelcomePacket { startTick = ServerTick, startState = CurrentState }; + } + + // Helper for tests to inspect internal timer (not part of public API) + public float GetTimer() => _timer; + } +} \ No newline at end of file diff --git a/Assets/Scripts/Entities/ServerEntity.cs.meta b/Assets/Scripts/Entities/ServerEntity.cs.meta new file mode 100644 index 0000000..fff204f --- /dev/null +++ b/Assets/Scripts/Entities/ServerEntity.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 6295794d79c702348b7c76d024471bac \ No newline at end of file diff --git a/Assets/Tests/Editor/DeterministicRollback.EditorTests.asmdef b/Assets/Tests/Editor/DeterministicRollback.EditorTests.asmdef index b739f53..015eec3 100644 --- a/Assets/Tests/Editor/DeterministicRollback.EditorTests.asmdef +++ b/Assets/Tests/Editor/DeterministicRollback.EditorTests.asmdef @@ -7,7 +7,8 @@ "GUID:e66b6e0ac2b1fbb4e83d0a5052b69efe", "GUID:75469ad4d38634e559750d17036d5f7c", "GUID:419a949562f3e254286b18f50facdc7c", - "GUID:c04d3943eda9bb04980532b6b11b1fa7" + "GUID:c04d3943eda9bb04980532b6b11b1fa7", + "DeterministicRollback.Entities" ], "includePlatforms": [ "Editor" diff --git a/Assets/Tests/Editor/Phase1EditModeNegativeTests.cs b/Assets/Tests/Editor/Phase1EditModeNegativeTests.cs new file mode 100644 index 0000000..8683277 --- /dev/null +++ b/Assets/Tests/Editor/Phase1EditModeNegativeTests.cs @@ -0,0 +1,42 @@ +using NUnit.Framework; +using DeterministicRollback.Core; + +namespace DeterministicRollback.Tests.Editor +{ + /// + /// Phase 1 negative/boundary tests for core data structures. + /// + public class Phase1EditModeNegativeTests + { + [Test] + public void RingBuffer_Write_OverwriteThenReadOldTick_ThrowsGhostDataException() + { + var buffer = new RingBuffer(4); + + // Fill capacity + for (uint t = 0; t < 4; t++) buffer[t] = (int)t; + + // Overwrite index 0 by writing tick 4 (wrap-around) + buffer[4] = 4; + + // Contains should report false for the old tick + Assert.IsFalse(buffer.Contains(0)); + + // Accessing old tick 0 should throw ghost-data exception + Assert.Throws(() => { var v = buffer[0]; }); + } + + [Test] + public void RingBuffer_Contains_ReturnsFalseAfterWrap() + { + var buffer = new RingBuffer(8); + + // Write two ticks spaced by buffer size to force wrap + buffer[5] = 123; + Assert.IsTrue(buffer.Contains(5)); + + buffer[5 + 8] = 456; // Overwrites slot for tick 5 + Assert.IsFalse(buffer.Contains(5)); + } + } +} \ No newline at end of file diff --git a/Assets/Tests/Editor/Phase1EditModeNegativeTests.cs.meta b/Assets/Tests/Editor/Phase1EditModeNegativeTests.cs.meta new file mode 100644 index 0000000..1755f6e --- /dev/null +++ b/Assets/Tests/Editor/Phase1EditModeNegativeTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: d07317abb24ae47428001983bf7b847a \ No newline at end of file diff --git a/Assets/Tests/Editor/Phase1Tests.cs b/Assets/Tests/Editor/Phase1EditModeTests.cs similarity index 99% rename from Assets/Tests/Editor/Phase1Tests.cs rename to Assets/Tests/Editor/Phase1EditModeTests.cs index a32d2c9..88bc7de 100644 --- a/Assets/Tests/Editor/Phase1Tests.cs +++ b/Assets/Tests/Editor/Phase1EditModeTests.cs @@ -8,7 +8,7 @@ namespace DeterministicRollback.Tests.Editor /// Phase 1 tests - Pure unit tests for core data structures and deterministic physics. /// All tests are Edit Mode compatible (no scene/MonoBehaviour dependencies). /// - public class Phase1Tests + public class Phase1EditModeTests { // ========== Step 1.2: RingBuffer Tests ========== diff --git a/Assets/Tests/Editor/Phase1EditModeTests.cs.meta b/Assets/Tests/Editor/Phase1EditModeTests.cs.meta new file mode 100644 index 0000000..56c3e4c --- /dev/null +++ b/Assets/Tests/Editor/Phase1EditModeTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: daf757c5f4f416c45946a34154ea98c5 \ No newline at end of file diff --git a/Assets/Tests/Editor/Phase1Tests.cs.meta b/Assets/Tests/Editor/Phase1Tests.cs.meta deleted file mode 100644 index 37c4605..0000000 --- a/Assets/Tests/Editor/Phase1Tests.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 393454a5e97bc5743acf1d5c02a35d5b \ No newline at end of file diff --git a/Assets/Tests/Editor/Phase2EditModeNegativeTests.cs b/Assets/Tests/Editor/Phase2EditModeNegativeTests.cs new file mode 100644 index 0000000..9850e2b --- /dev/null +++ b/Assets/Tests/Editor/Phase2EditModeNegativeTests.cs @@ -0,0 +1,39 @@ +using NUnit.Framework; +using UnityEngine; +using DeterministicRollback.Core; + +namespace DeterministicRollback.Tests.Editor +{ + /// + /// Phase 2 negative/boundary tests for deterministic simulation math. + /// + public class Phase2EditModeNegativeTests + { + [Test] + public void SimulationMath_Integrate_ExtremeInput_DoesNotProduceNaN() + { + var state = new StatePayload { position = Vector2.zero, velocity = Vector2.zero, tick = 0, confirmedInputTick = 0 }; + var input = new InputPayload { tick = 1, inputVector = new Vector2(1e30f, -1e30f) }; + + SimulationMath.Integrate(ref state, ref input); + + // Ensure resulting components are finite (not NaN or Infinity) + Assert.IsFalse(float.IsNaN(state.position.x) || float.IsInfinity(state.position.x)); + Assert.IsFalse(float.IsNaN(state.position.y) || float.IsInfinity(state.position.y)); + Assert.IsFalse(float.IsNaN(state.velocity.x) || float.IsInfinity(state.velocity.x)); + Assert.IsFalse(float.IsNaN(state.velocity.y) || float.IsInfinity(state.velocity.y)); + } + + [Test] + public void SimulationMath_Integrate_PreservesConfirmedInputTick() + { + var state = new StatePayload { position = Vector2.zero, velocity = Vector2.zero, tick = 0, confirmedInputTick = 123 }; + var input = new InputPayload { tick = 10, inputVector = Vector2.right }; + + SimulationMath.Integrate(ref state, ref input); + + // confirmedInputTick should remain unchanged by pure Integrate + Assert.AreEqual(123u, state.confirmedInputTick); + } + } +} \ No newline at end of file diff --git a/Assets/Tests/Editor/Phase2EditModeNegativeTests.cs.meta b/Assets/Tests/Editor/Phase2EditModeNegativeTests.cs.meta new file mode 100644 index 0000000..9c70c87 --- /dev/null +++ b/Assets/Tests/Editor/Phase2EditModeNegativeTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f5f42b6330ca49c468deb86b96986452 \ No newline at end of file diff --git a/Assets/Tests/Editor/Phase3EditModeNegativeTests.cs b/Assets/Tests/Editor/Phase3EditModeNegativeTests.cs new file mode 100644 index 0000000..4120cd5 --- /dev/null +++ b/Assets/Tests/Editor/Phase3EditModeNegativeTests.cs @@ -0,0 +1,55 @@ +using System; +using NUnit.Framework; +using UnityEngine; +using DeterministicRollback.Entities; +using DeterministicRollback.Networking; + +namespace DeterministicRollback.Tests.Editor +{ + /// + /// Phase 3 negative and boundary tests. + /// These tests focus on failure modes and edge cases (ghost data, accumulator backlog). + /// + public class Phase3EditModeNegativeTests + { + [SetUp] + public void SetUp() + { + // Ensure deterministic starting conditions + FakeNetworkPipe.Clear(); + Time.timeScale = 1f; + } + + [Test] + public void ClientEntity_SpiralGuard_PreservesAccumulator() + { + var client = new ClientEntity(); + client.Initialize(); + + // Simulate 20 ticks worth of deltaTime in one frame + client.UpdateWithDelta(20f / 60f); + + // Should clamp to MAX_TICKS_PER_FRAME (10) + Assert.AreEqual(10u, client.CurrentTick); + + // Next call with no additional time should process the backlog (remaining 10 ticks) + client.UpdateWithDelta(0f); + Assert.AreEqual(20u, client.CurrentTick); + } + + [Test] + public void RingBuffer_Wraparound_ThrowsGhostDataException() + { + var buffer = new DeterministicRollback.Core.RingBuffer(4); + + // Fill capacity + for (uint t = 0; t < 4; t++) buffer[t] = (int)t; + + // Overwrite index 0 by writing tick 4 (wrap-around) + buffer[4] = 4; + + // Accessing old tick 0 should throw ghost-data exception + Assert.Throws(() => { var v = buffer[0]; }); + } + } +} diff --git a/Assets/Tests/Editor/Phase3EditModeNegativeTests.cs.meta b/Assets/Tests/Editor/Phase3EditModeNegativeTests.cs.meta new file mode 100644 index 0000000..5cfeea7 --- /dev/null +++ b/Assets/Tests/Editor/Phase3EditModeNegativeTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: b6441562ca75ed248add018345e5d15a \ No newline at end of file diff --git a/Assets/Tests/Editor/Phase3EditModeTests.cs b/Assets/Tests/Editor/Phase3EditModeTests.cs new file mode 100644 index 0000000..fc4d473 --- /dev/null +++ b/Assets/Tests/Editor/Phase3EditModeTests.cs @@ -0,0 +1,108 @@ +using System; +using NUnit.Framework; +using UnityEngine; +using DeterministicRollback.Entities; +using DeterministicRollback.Networking; + +namespace DeterministicRollback.Tests.Editor +{ + /// + /// Phase 3 tests - Client entity simulation with time accumulator and redundant input. + /// + public class Phase3EditModeTests + { + [SetUp] + public void SetUp() + { + // Ensure deterministic starting conditions + FakeNetworkPipe.Clear(); + Time.timeScale = 1f; + } + + [Test] + public void ClientEntity_TickRate() + { + var client = new ClientEntity(); + client.Initialize(); + + // Run 6000 frames (100 seconds at 60Hz) + for (int i = 0; i < 6000; i++) + { + client.UpdateWithDelta(1f / 60f); + } + + // currentTick should be 6000 (0 is spawn, 1-6000 simulated) + Assert.AreEqual(6000u, client.CurrentTick); + } + + [Test] + public void ClientEntity_DeterministicPath() + { + var client1 = new ClientEntity(); + var client2 = new ClientEntity(); + + // Provide deterministic identical input + client1.InputProvider = () => Vector2.right; + client2.InputProvider = () => Vector2.right; + + int ticks = 300; // 5 seconds + for (int i = 0; i < ticks; i++) + { + client1.UpdateWithDelta(1f / 60f); + client2.UpdateWithDelta(1f / 60f); + } + + // Verify both clients have identical StateBuffer contents + for (uint t = 0; t <= (uint)ticks; t++) + { + var s1 = client1.GetState(t); + var s2 = client2.GetState(t); + + Assert.AreEqual(s1.position.x, s2.position.x, 0.0001f); + Assert.AreEqual(s1.position.y, s2.position.y, 0.0001f); + Assert.AreEqual(s1.velocity.x, s2.velocity.x, 0.0001f); + Assert.AreEqual(s1.velocity.y, s2.velocity.y, 0.0001f); + } + } + + [Test] + public void ClientEntity_SendsBatchesToNetwork() + { + var client = new ClientEntity(); + int received = 0; + FakeNetworkPipe.Clear(); + FakeNetworkPipe.OnInputBatchReceived += (batch) => received++; + + // Run a few ticks + for (int i = 0; i < 10; i++) + { + client.UpdateWithDelta(1f / 60f); + // Process pending packets immediately (simulated network) + FakeNetworkPipe.ProcessPackets(); + } + + Assert.Greater(received, 0); + } + + // Negative tests moved to `Assets/Tests/Editor/Phase3EditModeNegativeTests.cs` + // See: ClientEntity_SpiralGuard_PreservesAccumulator (boundary/backlog) and RingBuffer_Wraparound_ThrowsGhostDataException (ghost-data protection) for negative test coverage. + + // Negative tests moved to `Assets/Tests/Editor/Phase3EditModeNegativeTests.cs` + // See: RingBuffer_Wraparound_ThrowsGhostDataException (ghost-data protection) and related tests. + +#if UNITY_EDITOR + [Test] + public void ClientEntity_ZeroGC_PerTick() + { + // Verify per-tick Update does not allocate memory in hot path + long allocBefore = UnityEngine.Profiling.Profiler.GetTotalAllocatedMemoryLong(); + + var client = new ClientEntity(); + client.UpdateWithDelta(1f / 60f); + + long allocAfter = UnityEngine.Profiling.Profiler.GetTotalAllocatedMemoryLong(); + Assert.AreEqual(0L, allocAfter - allocBefore, "Per-tick Update allocated GC memory"); + } +#endif + } +} \ No newline at end of file diff --git a/Assets/Tests/Editor/Phase3EditModeTests.cs.meta b/Assets/Tests/Editor/Phase3EditModeTests.cs.meta new file mode 100644 index 0000000..e49f2f0 --- /dev/null +++ b/Assets/Tests/Editor/Phase3EditModeTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3bcf9058b4d257b4e8e79c56726d54a7 \ No newline at end of file diff --git a/Assets/Tests/Editor/Phase4EditModeNegativeTests.cs b/Assets/Tests/Editor/Phase4EditModeNegativeTests.cs new file mode 100644 index 0000000..6f0a6db --- /dev/null +++ b/Assets/Tests/Editor/Phase4EditModeNegativeTests.cs @@ -0,0 +1,94 @@ +using NUnit.Framework; +using UnityEngine; +using DeterministicRollback.Entities; +using DeterministicRollback.Networking; +using DeterministicRollback.Core; + +namespace DeterministicRollback.Tests.Editor +{ + public class Phase4EditModeNegativeTests + { + [SetUp] + public void SetUp() + { + FakeNetworkPipe.Clear(); + Time.timeScale = 1f; + } + + [Test] + public void ServerEntity_SpiralGuard_PreservesAccumulator() + { + var go = new GameObject("server"); + var server = go.AddComponent(); + server.Initialize(); + + // Provide a huge delta that would require > MAX_TICKS_PER_FRAME + server.UpdateWithDelta(1.0f); // 1 second ~ 60 ticks + + // Should have processed at most MAX_TICKS_PER_FRAME ticks + Assert.LessOrEqual(server.ticksProcessed, ServerEntity.MAX_TICKS_PER_FRAME); + + // Timer should not be reset to zero (some backlog remains) + Assert.Greater(server.GetTimer(), 0f); + + Object.DestroyImmediate(go); + } + + [Test] + public void ServerEntity_InputBuffer_RejectsOldTicks() + { + var go = new GameObject("server"); + var server = go.AddComponent(); + server.Initialize(); + + // Attempt to insert an old tick (less than serverTick) + var oldInput = new InputPayload { tick = 0, inputVector = Vector2.right }; + // Simulate receiving directly via event + var batch = new InputBatch { i0 = oldInput, count = 1 }; + FakeNetworkPipe.SendInput(batch, 0f, 0f); + FakeNetworkPipe.ProcessPackets(); + FakeNetworkPipe.ProcessPackets(); + + Assert.IsFalse(server.ServerInputBuffer.Contains(0)); + Object.DestroyImmediate(go); + } + + [Test] + public void ServerEntity_InputBuffer_RejectsFutureTicks() + { + var go = new GameObject("server"); + var server = go.AddComponent(); + server.Initialize(); + + // Insert a tick far in the future (beyond buffer capacity) + var futureInput = new InputPayload { tick = server.ServerTick + 5000, inputVector = Vector2.right }; + var batch = new InputBatch { i0 = futureInput, count = 1 }; + FakeNetworkPipe.SendInput(batch, 0f, 0f); + FakeNetworkPipe.ProcessPackets(); + + Assert.IsFalse(server.ServerInputBuffer.Contains(futureInput.tick)); + Object.DestroyImmediate(go); + } + + [Test] + public void ServerEntity_ConfirmedInputTick_NeverDecreases() + { + var go = new GameObject("server"); + var server = go.AddComponent(); + server.Initialize(); + + var batch1 = new InputBatch { i0 = new InputPayload { tick = 5 }, count = 1 }; + var batch2 = new InputBatch { i0 = new InputPayload { tick = 3 }, count = 1 }; + + FakeNetworkPipe.SendInput(batch1, 0f, 0f); + FakeNetworkPipe.SendInput(batch2, 0f, 0f); + FakeNetworkPipe.ProcessPackets(); + + // Process a few ticks to update lastConfirmedInputTick + for (int i = 0; i < 3; i++) server.UpdateWithDelta(1f / 60f); + + Assert.GreaterOrEqual(server.lastConfirmedInputTick, 5u); + Object.DestroyImmediate(go); + } + } +} \ No newline at end of file diff --git a/Assets/Tests/Editor/Phase4EditModeNegativeTests.cs.meta b/Assets/Tests/Editor/Phase4EditModeNegativeTests.cs.meta new file mode 100644 index 0000000..dfeb6c6 --- /dev/null +++ b/Assets/Tests/Editor/Phase4EditModeNegativeTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: a65107c7157d65241bbf5a6a5aa718f6 \ No newline at end of file diff --git a/Assets/Tests/Editor/Phase4EditModeTests.cs b/Assets/Tests/Editor/Phase4EditModeTests.cs new file mode 100644 index 0000000..6eb07e7 --- /dev/null +++ b/Assets/Tests/Editor/Phase4EditModeTests.cs @@ -0,0 +1,112 @@ +using NUnit.Framework; +using UnityEngine; +using DeterministicRollback.Entities; +using DeterministicRollback.Networking; +using DeterministicRollback.Core; + +namespace DeterministicRollback.Tests.Editor +{ + public class Phase4EditModeTests + { + [SetUp] + public void SetUp() + { + // Ensure FakeNetworkPipe is clean and time is running + FakeNetworkPipe.Clear(); + Time.timeScale = 1f; + } + + [Test] + public void ServerEntity_TickRate() + { + var go = new GameObject("server"); + var server = go.AddComponent(); + server.Initialize(); + + uint initial = server.ServerTick; + for (int i = 0; i < 6000; i++) + { + server.UpdateWithDelta(1f / 60f); + } + + Assert.AreEqual(6000u, server.ServerTick - initial); + Object.DestroyImmediate(go); + } + + [Test] + public void ServerEntity_ReceiveInputBatch() + { + var go = new GameObject("server"); + var server = go.AddComponent(); + server.Initialize(); + + var batch = new InputBatch { i0 = new InputPayload { tick = 5, inputVector = Vector2.right }, count = 1 }; + FakeNetworkPipe.SendInput(batch, 0f, 0f); + // Deliver immediately in EditMode tests (run twice to avoid timing flakiness) + FakeNetworkPipe.ProcessPackets(); + FakeNetworkPipe.ProcessPackets(); + + Assert.IsTrue(server.ServerInputBuffer.Contains(5)); + Object.DestroyImmediate(go); + } + + [Test] + public void ServerEntity_InputPrediction() + { + var go = new GameObject("server"); + var server = go.AddComponent(); + server.Initialize(); + + var batch = new InputBatch { i0 = new InputPayload { tick = 10, inputVector = Vector2.right }, count = 1 }; + FakeNetworkPipe.SendInput(batch, 0f, 0f); + FakeNetworkPipe.ProcessPackets(); + FakeNetworkPipe.ProcessPackets(); + + // Simulate 15 ticks + for (int i = 0; i < 15; i++) server.UpdateWithDelta(1f / 60f); + + Assert.Greater(server.ServerTick, 10u); + Object.DestroyImmediate(go); + } + + [Test] + public void ServerEntity_InputConfirmation() + { + var go = new GameObject("server"); + var server = go.AddComponent(); + server.Initialize(); + + var batch1 = new InputBatch { i0 = new InputPayload { tick = 5, inputVector = Vector2.right }, count = 1 }; + var batch2 = new InputBatch { i0 = new InputPayload { tick = 7, inputVector = Vector2.right }, count = 1 }; + var batch3 = new InputBatch { i0 = new InputPayload { tick = 9, inputVector = Vector2.right }, count = 1 }; + + FakeNetworkPipe.SendInput(batch1, 0f, 0f); + FakeNetworkPipe.SendInput(batch2, 0f, 0f); + FakeNetworkPipe.SendInput(batch3, 0f, 0f); + FakeNetworkPipe.ProcessPackets(); + + // Process a few ticks to let server apply confirmations + for (int i = 0; i < 5; i++) server.UpdateWithDelta(1f / 60f); + + Assert.AreEqual(9u, server.lastConfirmedInputTick); + Object.DestroyImmediate(go); + } + + [Test] + public void ServerEntity_ZeroGC_PerTick() + { + var go = new GameObject("server"); + var server = go.AddComponent(); + server.Initialize(); + + long before = UnityEngine.Profiling.Profiler.GetTotalAllocatedMemoryLong(); + for (int i = 0; i < 100; i++) server.UpdateWithDelta(1f / 60f); + long after = UnityEngine.Profiling.Profiler.GetTotalAllocatedMemoryLong(); + + // Allow small noise, but ensure no large allocations in hot path + Assert.IsTrue(after - before < 1024); + + Object.DestroyImmediate(go); + } + } +} \ No newline at end of file diff --git a/Assets/Tests/Editor/Phase4EditModeTests.cs.meta b/Assets/Tests/Editor/Phase4EditModeTests.cs.meta new file mode 100644 index 0000000..18820e9 --- /dev/null +++ b/Assets/Tests/Editor/Phase4EditModeTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 324b64b66774fae4882eea6b000a1b80 \ No newline at end of file diff --git a/Packages/manifest.json b/Packages/manifest.json index 63b8061..aa6a672 100644 --- a/Packages/manifest.json +++ b/Packages/manifest.json @@ -1,5 +1,8 @@ { "dependencies": { + "com.unity.ai.assistant": "1.0.0-pre.12", + "com.unity.ai.generators": "1.0.0-pre.20", + "com.unity.ai.inference": "2.4.1", "com.unity.multiplayer.center": "1.0.1", "com.unity.test-framework": "1.6.0", "com.unity.modules.accessibility": "1.0.0", diff --git a/Packages/packages-lock.json b/Packages/packages-lock.json index c56545c..4d4c03d 100644 --- a/Packages/packages-lock.json +++ b/Packages/packages-lock.json @@ -1,11 +1,98 @@ { "dependencies": { + "com.unity.ai.assistant": { + "version": "1.0.0-pre.12", + "depth": 0, + "source": "registry", + "dependencies": { + "com.unity.ai.toolkit": "1.0.0-pre.18", + "com.unity.serialization": "3.1.1", + "com.unity.nuget.newtonsoft-json": "3.2.1", + "com.unity.modules.unitywebrequest": "1.0.0" + }, + "url": "https://packages.unity.com" + }, + "com.unity.ai.generators": { + "version": "1.0.0-pre.20", + "depth": 0, + "source": "registry", + "dependencies": { + "com.unity.ai.toolkit": "1.0.0-pre.20", + "com.unity.mathematics": "1.3.2", + "com.unity.nuget.newtonsoft-json": "3.2.1" + }, + "url": "https://packages.unity.com" + }, + "com.unity.ai.inference": { + "version": "2.4.1", + "depth": 0, + "source": "registry", + "dependencies": { + "com.unity.burst": "1.8.17", + "com.unity.dt.app-ui": "1.3.1", + "com.unity.collections": "2.4.3", + "com.unity.nuget.newtonsoft-json": "3.2.1", + "com.unity.modules.imageconversion": "1.0.0" + }, + "url": "https://packages.unity.com" + }, + "com.unity.ai.toolkit": { + "version": "1.0.0-pre.20", + "depth": 1, + "source": "registry", + "dependencies": { + "com.unity.nuget.newtonsoft-json": "3.2.1" + }, + "url": "https://packages.unity.com" + }, + "com.unity.burst": { + "version": "1.8.26", + "depth": 1, + "source": "registry", + "dependencies": { + "com.unity.mathematics": "1.2.1", + "com.unity.modules.jsonserialize": "1.0.0" + }, + "url": "https://packages.unity.com" + }, + "com.unity.collections": { + "version": "2.6.2", + "depth": 1, + "source": "registry", + "dependencies": { + "com.unity.burst": "1.8.23", + "com.unity.mathematics": "1.3.2", + "com.unity.test-framework": "1.4.6", + "com.unity.nuget.mono-cecil": "1.11.5", + "com.unity.test-framework.performance": "3.0.3" + }, + "url": "https://packages.unity.com" + }, + "com.unity.dt.app-ui": { + "version": "2.1.1", + "depth": 1, + "source": "registry", + "dependencies": { + "com.unity.modules.physics": "1.0.0", + "com.unity.modules.androidjni": "1.0.0", + "com.unity.modules.uielements": "1.0.0", + "com.unity.modules.screencapture": "1.0.0" + }, + "url": "https://packages.unity.com" + }, "com.unity.ext.nunit": { "version": "2.0.5", "depth": 1, "source": "builtin", "dependencies": {} }, + "com.unity.mathematics": { + "version": "1.3.3", + "depth": 1, + "source": "registry", + "dependencies": {}, + "url": "https://packages.unity.com" + }, "com.unity.multiplayer.center": { "version": "1.0.1", "depth": 0, @@ -14,6 +101,30 @@ "com.unity.modules.uielements": "1.0.0" } }, + "com.unity.nuget.mono-cecil": { + "version": "1.11.6", + "depth": 2, + "source": "registry", + "dependencies": {}, + "url": "https://packages.unity.com" + }, + "com.unity.nuget.newtonsoft-json": { + "version": "3.2.2", + "depth": 1, + "source": "registry", + "dependencies": {}, + "url": "https://packages.unity.com" + }, + "com.unity.serialization": { + "version": "3.1.3", + "depth": 1, + "source": "registry", + "dependencies": { + "com.unity.burst": "1.7.2", + "com.unity.collections": "2.4.2" + }, + "url": "https://packages.unity.com" + }, "com.unity.test-framework": { "version": "1.6.0", "depth": 0, @@ -24,6 +135,16 @@ "com.unity.modules.jsonserialize": "1.0.0" } }, + "com.unity.test-framework.performance": { + "version": "3.2.0", + "depth": 2, + "source": "registry", + "dependencies": { + "com.unity.test-framework": "1.1.33", + "com.unity.modules.jsonserialize": "1.0.0" + }, + "url": "https://packages.unity.com" + }, "com.unity.modules.accessibility": { "version": "1.0.0", "depth": 0, diff --git a/ProjectSettings/EditorBuildSettings.asset b/ProjectSettings/EditorBuildSettings.asset index 7df81c6..819692d 100644 --- a/ProjectSettings/EditorBuildSettings.asset +++ b/ProjectSettings/EditorBuildSettings.asset @@ -5,5 +5,6 @@ EditorBuildSettings: m_ObjectHideFlags: 0 serializedVersion: 2 m_Scenes: [] - m_configObjects: {} + m_configObjects: + com.unity.dt.app-ui: {fileID: 11400000, guid: 1b1c20d82303e4b5781c3ef50ac1449f, type: 2} m_UseUCBPForAssetBundles: 0 diff --git a/ProjectSettings/ProjectSettings.asset b/ProjectSettings/ProjectSettings.asset index 2493a3b..bb0b2e6 100644 --- a/ProjectSettings/ProjectSettings.asset +++ b/ProjectSettings/ProjectSettings.asset @@ -17,8 +17,8 @@ PlayerSettings: defaultCursor: {fileID: 0} cursorHotspot: {x: 0, y: 0} m_SplashScreenBackgroundColor: {r: 0.12156863, g: 0.12156863, b: 0.1254902, a: 1} - m_ShowUnitySplashScreen: 0 - m_ShowUnitySplashLogo: 0 + m_ShowUnitySplashScreen: 1 + m_ShowUnitySplashLogo: 1 m_SplashScreenOverlayOpacity: 1 m_SplashScreenAnimation: 1 m_SplashScreenLogoStyle: 1 @@ -83,7 +83,7 @@ PlayerSettings: androidApplicationEntry: 2 defaultIsNativeResolution: 1 macRetinaSupport: 1 - runInBackground: 1 + runInBackground: 0 muteOtherAudioSources: 0 Prepare IOS For Recording: 0 Force IOS Speakers When Recording: 0 @@ -96,7 +96,7 @@ PlayerSettings: bakeCollisionMeshes: 0 forceSingleInstance: 0 useFlipModelSwapchain: 1 - resizableWindow: 1 + resizableWindow: 0 useMacAppStoreValidation: 0 macAppStoreCategory: public.app-category.games gpuSkinning: 0 @@ -108,7 +108,7 @@ PlayerSettings: xboxEnableFitness: 0 visibleInBackground: 1 allowFullscreenSwitch: 1 - fullscreenMode: 3 + fullscreenMode: 1 xboxSpeechDB: 0 xboxEnableHeadOrientation: 0 xboxEnableGuest: 0 @@ -583,7 +583,8 @@ PlayerSettings: webGLCloseOnQuit: 0 webWasm2023: 0 webEnableSubmoduleStrippingCompatibility: 0 - scriptingDefineSymbols: {} + scriptingDefineSymbols: + Standalone: APP_UI_EDITOR_ONLY additionalCompilerArguments: {} platformArchitecture: {} scriptingBackend: {} diff --git a/SELF_HOSTED_RUNNER_SETUP.md b/SELF_HOSTED_RUNNER_SETUP.md new file mode 100644 index 0000000..f7a8f2b --- /dev/null +++ b/SELF_HOSTED_RUNNER_SETUP.md @@ -0,0 +1,111 @@ +# Self-Hosted GitHub Actions Runner Setup + +## Why Self-Hosted Runner? + +Unity Personal licenses can't be activated programmatically in cloud CI runners. A self-hosted runner uses your machine where Unity is already activated. + +## Setup Steps + +### 1. Install GitHub Actions Runner on Your Machine + +1. Go to: https://github.com/harmandeeppal/deterministic-rollback-toy/settings/actions/runners/new + +2. Follow the displayed instructions, or use these commands in PowerShell: + +```powershell +# Create a folder +cd C:\ +mkdir actions-runner; cd actions-runner + +# Download the latest runner package +Invoke-WebRequest -Uri https://github.com/actions/runner/releases/download/v2.321.0/actions-runner-win-x64-2.321.0.zip -OutFile actions-runner-win-x64-2.321.0.zip + +# Extract the installer +Add-Type -AssemblyName System.IO.Compression.FileSystem +[System.IO.Compression.ZipFile]::ExtractToDirectory("$PWD/actions-runner-win-x64-2.321.0.zip", "$PWD") +``` + +### 2. Configure the Runner + +```powershell +# Get your token from https://github.com/harmandeeppal/deterministic-rollback-toy/settings/actions/runners/new +.\config.cmd --url https://github.com/harmandeeppal/deterministic-rollback-toy --token YOUR_TOKEN_HERE + +# When prompted: +# - Runner group: Default +# - Runner name: (press Enter to use default, e.g., "DESKTOP-XYZ") +# - Labels: unity-windows (or press Enter for default) +# - Work folder: (press Enter for default) +``` + +### 3. Run the Runner + +```powershell +# Start the runner (keep this terminal open) +.\run.cmd +``` + +**Or install as a Windows Service** (recommended for always-on): + +```powershell +# Run as Administrator: +.\svc.cmd install +.\svc.cmd start +``` + +### 4. Update Workflow to Use Self-Hosted Runner + +The workflow needs a small change to target your runner instead of `ubuntu-latest`. + +**Current:** +```yaml +runs-on: ubuntu-latest +``` + +**Updated:** +```yaml +runs-on: self-hosted +``` + +## Workflow Changes Needed + +Update `.github/workflows/unity-tests.yml`: + +1. Change `runs-on: ubuntu-latest` to `runs-on: self-hosted` +2. Remove Unity activation steps (not needed - Unity already activated) +3. Use Windows paths if needed + +## Verify Runner is Online + +Check: https://github.com/harmandeeppal/deterministic-rollback-toy/settings/actions/runners + +You should see your runner listed with a green dot (online). + +## Pros/Cons + +### Pros +✅ Works immediately with Personal license +✅ No license secrets needed +✅ Faster builds (no Docker overhead) +✅ Can test Windows-specific features + +### Cons +❌ Machine must be running for CI to work +❌ Ties up your machine during builds +❌ Only tests on your OS/Unity version + +## Alternative: Use Ubuntu VM + +If you want a dedicated CI machine: +1. Set up an Ubuntu VM (VirtualBox/Hyper-V) +2. Install Unity on VM and activate Personal license +3. Install GitHub Actions runner on VM +4. Leave VM running 24/7 + +## Next Steps + +1. Choose: self-hosted runner vs. cloud runner with Pro license +2. If self-hosted: follow steps above +3. Update workflow file +4. Test with a push to the branch +