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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions .github/workflows/unity-tests.yml
Original file line number Diff line number Diff line change
@@ -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
5 changes: 5 additions & 0 deletions Assets/Scripts/Core/RingBuffer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,11 @@ public bool Contains(uint tick)
return slot.isValid && slot.tick == tick;
}

/// <summary>
/// Expose capacity for validation and bounds checks.
/// </summary>
public uint Capacity => _capacity;

/// <summary>
/// Invalidate range of ticks (inclusive).
/// Used during hard snap to clear diverged speculative data.
Expand Down
170 changes: 170 additions & 0 deletions Assets/Scripts/Entities/ClientEntity.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
using System;
using UnityEngine;
using DeterministicRollback.Core;
using DeterministicRollback.Networking;

namespace DeterministicRollback.Entities
{
/// <summary>
/// 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.
/// </summary>
/// <summary>
/// Testable client-side simulation core. Runs a fixed-step 60Hz simulation using an accumulator.
/// Handles input capture, batch sending, deterministic integration and StateBuffer writes.
/// </summary>
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<InputPayload> InputBuffer { get; private set; }
public RingBuffer<StatePayload> 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<Vector2> InputProvider = () => Vector2.zero;

public ClientEntity()
{
Initialize();
}

/// <summary>
/// Initialize buffers and spawn state. Safe to call multiple times to reset client state.
/// </summary>
public void Initialize()
{
InputBuffer = new RingBuffer<InputPayload>(4096);
StateBuffer = new RingBuffer<StatePayload>(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;
}

/// <summary>
/// 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.
/// </summary>
/// <summary>
/// Advance simulation by consuming accumulated time (call every frame).
/// Runs up to <see cref="MAX_TICKS_PER_FRAME"/> ticks to prevent spiral-of-death.
/// This method is a hot path and must not allocate GC memory per tick.
/// </summary>
/// <summary>
/// Advance simulation by consuming accumulated time (call every frame).
/// Runs up to <see cref="MAX_TICKS_PER_FRAME"/> ticks to prevent spiral-of-death.
/// This method is a hot path and must not allocate GC memory per tick.
/// </summary>
public void Update()
{
UpdateWithDelta(Time.deltaTime);
}

/// <summary>
/// Advance simulation using an explicit deltaTime. Useful for deterministic EditMode tests.
/// </summary>
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");
}
}

/// <summary>
/// Helper to access a stored state for assertions in tests.
/// Returns default StatePayload if not present.
/// </summary>
/// <summary>
/// Retrieve recorded post-integration StatePayload for a given tick.
/// Returns default(StatePayload) if history does not contain that tick.
/// </summary>
public StatePayload GetState(uint tick)
{
return StateBuffer.Contains(tick) ? StateBuffer[tick] : default;
}
}
}
2 changes: 2 additions & 0 deletions Assets/Scripts/Entities/ClientEntity.cs.meta

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

61 changes: 61 additions & 0 deletions Assets/Scripts/Entities/ClientEntityBehaviour.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
using UnityEngine;
using DeterministicRollback.Entities;

namespace DeterministicRollback.Behaviours
{
/// <summary>
/// MonoBehaviour wrapper for ClientEntity. Supplies input and mirrors inspector-configurable network parameters.
/// Provides deterministic Auto-Move mode for tests and demo scenarios.
/// </summary>
public class ClientEntityBehaviour : MonoBehaviour
{
/// <summary>
/// One-way latency in milliseconds used for FakeNetworkPipe.SendInput()
/// </summary>
public float latencyMs = 0f;
[Range(0f, 1f)] public float lossChance = 0f;
public bool autoMove = false;
private ClientEntity _client;

/// <summary>
/// Initialize the underlying ClientEntity and set up input provider.
/// </summary>
void Start()
{
_client = new ClientEntity();
_client.latencyMs = latencyMs;
_client.lossChance = lossChance;
_client.InputProvider = ReadInput;
}

/// <summary>
/// Forward Unity Update() to the testable ClientEntity core.
/// Keeps runtime-configurable parameters in sync.
/// </summary>
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;
}
}
}
2 changes: 2 additions & 0 deletions Assets/Scripts/Entities/ClientEntityBehaviour.cs.meta

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading