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
2 changes: 1 addition & 1 deletion .github/FUNDING.yml
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
# These are supported funding model platforms
# github: [BlenMiner, BobsiUnity]
custom: ['https://whop.com/purrnet/']
custom: ['https://purrnet.dev/pricing']
3 changes: 3 additions & 0 deletions .github/workflows/prediction-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,9 @@ jobs:
run_profile high-jitter 37003 -latencyMin 120 -latencyMax 250
run_profile loss 37004 -latencyMin 40 -latencyMax 80 -packetLoss 5
run_profile heavy-loss 37005 -latencyMin 80 -latencyMax 160 -packetLoss 15
run_profile purrleague-immediate-rpc 37006 -tickRate 60 -mtuFragment -desyncPolicy Report -immediateRpcRegressionScenarioOnly
run_profile webtransport-immediate-rpc 37007 -tickRate 60 -mtuFragment -desyncPolicy Report -webTransport -immediateRpcRegressionScenarioOnly
run_profile purrleague-immediate-rpc-no-player 37008 -tickRate 60 -mtuFragment -desyncPolicy Report -immediateRpcRegressionScenarioOnly -immediateRpcRegressionWithoutPlayer

exit $FAIL

Expand Down
16 changes: 16 additions & 0 deletions Assets/PredictionTests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ Multi-process end-to-end tests for the prediction pipeline, modeled after PurrNe
| Scenario | What it guards |
|---|---|
| `PredictionBootstrap` | Connection + PredictionManager spawn/tick on every peer |
| `ImmediatePredictionRpcRegressionScenario` | Sustained bidirectional immediate RPC traffic matching PurrDiction's input/frame signatures, plus PurrLeague-shaped predicted transform/rigidbody/input/state identities and real `PredictedPlayerSpawner` state under the inherited desync policy; varies nested payload lengths across batch and MTU boundaries and validates every fixed field and payload byte after compression/fragmentation |
| `BounceScenario` | Verified-gated physics events fire exactly once per physical event: a predicted rigidbody bounces and every peer's `isVerified`-gated, tick-deduped collision counter must equal the server's (repro for the multi-fire report) |
| `DeterministicAlignmentScenario` | Deterministic identities stay tick-aligned with synced state across the join seam; timed deterministic spawns produce identical instance ids everywhere (PurrNet v1.20.0-beta.160 regression class) |
| `PredictedPawnScenario` | Input round-trip, per-player owned identities, input-driven hierarchy spawns converge |
Expand Down Expand Up @@ -44,6 +45,21 @@ PurrDictionTests -batchmode -nographics -role client -count 3 -results client-2.

Optional args: `-port`, `-serverHost`, `-connectTimeout`. Exit code is non-zero if any scenario fails. CI runs this via `.github/workflows/prediction-tests.yml` (server and host matrix, IL2CPP).

Pass `-immediateRpcRegressionScenarioOnly` to run only bootstrap plus the immediate-RPC
regression. Its default 180-prediction-tick burst can be changed with
`-immediateRpcRegressionFrames`; the burst is paced by prediction ticks rather than uncapped
headless render frames. Add `-immediateRpcRegressionWithoutPlayer` to disable the scene's
`PredictedPlayerSpawner` and assert that the client receives the correct built-in
`PredictedPlayers` membership while no owned gameplay identity exists.
Pass `-webTransport` to replace the scene's UDP transport with WebTransport; CI combines these
flags in a focused profile so the WebSocket-backed path is covered as well as UDP.
`-tickRate` overrides the scene's default 20 Hz rate and `-mtuFragment` selects the global
unreliable-fragment behavior. CI's focused UDP and WebTransport profiles use 60 Hz plus Fragment
to match the PurrLeague project settings that exposed this regression. They also pass
`-desyncPolicy Report`, which enables the new inherited deterministic-state reports while the
immediate prediction RPC traffic is active. The policy accepts `Ignore`, `Report`, `Resync`, or
`Correct`.

Policy regression scenarios are included in the normal suite. Pass `-policyRegressionScenariosOnly`
to run just the bootstrap and the three focused policy scenarios.

Expand Down
103 changes: 95 additions & 8 deletions Assets/PredictionTests/Scripts/PredictionBootstrap.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ public class PredictionBootstrap : Scenario
private string _serverHost;
private ushort? _port;
private bool _profileScenarios;
private bool _useWebTransport;
private bool _autoStartRegression;

private Scenario[] _scenarios;
private ScenarioDetails?[] _results;
Expand All @@ -59,6 +61,14 @@ private void Awake()
_predictionManager.predictedPrefabs = prefabs;

LoadArgs();
SelectTransport();

_autoStartRegression = CommandLineUtils.HasFlag("-autoStartRegression");
if (_autoStartRegression)
{
ConfigureTransport();
ConfigureAutoStartFlags();
}

if (CommandLineUtils.HasFlag("-serverLoadBenchmark"))
{
Expand Down Expand Up @@ -93,9 +103,21 @@ private void Awake()
return;
}

if (CommandLineUtils.HasFlag("-immediateRpcRegressionScenarioOnly"))
{
_scenarios = new Scenario[]
{
this,
gameObject.AddComponent<ImmediatePredictionRpcRegressionScenario>()
};
_results = new ScenarioDetails?[_scenarios.Length];
return;
}

if (CommandLineUtils.HasFlag("-includeHistoryStressScenario"))
gameObject.AddComponent<HistoryStressScenario>();

gameObject.AddComponent<ImmediatePredictionRpcRegressionScenario>();
gameObject.AddComponent<TrailIntegrityScenario>();
gameObject.AddComponent<DesyncCorrectionScenario>();

Expand Down Expand Up @@ -273,11 +295,62 @@ private void LoadArgs()
&& int.TryParse(packetLoss, out var parsedPacketLoss))
_packetLossChance = parsedPacketLoss;

if (CommandLineUtils.TryGetArgument("-tickRate", out var tickRate) &&
int.TryParse(tickRate, out var parsedTickRate) && parsedTickRate > 0)
_networkManager.tickRate = parsedTickRate;

if (CommandLineUtils.HasFlag("-mtuFragment"))
_networkManager.mtuExceededBehaviour = MTUExceededBehaviour.Fragment;

if (CommandLineUtils.TryGetArgument("-desyncPolicy", out var desyncPolicy))
{
if (!Enum.TryParse(desyncPolicy, true, out DesyncPolicy parsedDesyncPolicy) ||
!Enum.IsDefined(typeof(DesyncPolicy), parsedDesyncPolicy))
{
Debug.LogError($"Could not parse -desyncPolicy value '{desyncPolicy}'");
Application.Quit(-1);
return;
}

// PredictionManager resolves inherited policies during setup, after this bootstrap Awake.
JsonUtility.FromJsonOverwrite(
$"{{\"_desyncPolicy\":{(byte)parsedDesyncPolicy}}}",
_predictionManager);
}

_profileScenarios = CommandLineUtils.HasFlag("-profileScenarios");
_useWebTransport = CommandLineUtils.HasFlag("-webTransport");
}

private void SelectTransport()
{
if (!_useWebTransport || _networkManager.transport is WebTransport)
return;

var webTransport = _networkManager.GetComponent<WebTransport>();
if (!webTransport)
webTransport = _networkManager.gameObject.AddComponent<WebTransport>();

_networkManager.transport = webTransport;
Debug.Log("[PredictionTests] Selected WebTransport from -webTransport");
}

private void ConfigureTransport()
{
if (_networkManager.transport is WebTransport web)
{
if (_port.HasValue)
web.serverPort = _port.Value;

if (_role == NetworkRole.Client && !string.IsNullOrEmpty(_serverHost))
web.address = _serverHost;

if (_role != NetworkRole.Client)
web.maxConnections = Mathf.Max(web.maxConnections, _expectedConnections + 8);

return;
}

if (_networkManager.transport is not UDPTransport udp)
return;

Expand All @@ -301,6 +374,17 @@ private void ConfigureTransport()
};
}

private void ConfigureAutoStartFlags()
{
var runtimeFlag = _role == NetworkRole.Server ? StartFlags.ServerBuild : StartFlags.ClientBuild;
_networkManager.startServerFlags = _role is NetworkRole.Server or NetworkRole.Host
? runtimeFlag
: StartFlags.None;
_networkManager.startClientFlags = _role is NetworkRole.Client or NetworkRole.Host
? runtimeFlag
: StartFlags.None;
}

private bool TryResolveRole(out NetworkRole resolved)
{
resolved = default;
Expand Down Expand Up @@ -329,14 +413,17 @@ private bool TryResolveRole(out NetworkRole resolved)

public override async UniTask<ScenarioResult> RunScenario(ScenarioContext ctx)
{
Assert.IsTrue(_networkManager.isOffline);
Assert.AreEqual(_networkManager.clientState, ConnectionState.Disconnected, "Client is not connected");
Assert.AreEqual(_networkManager.serverState, ConnectionState.Disconnected, "Server is not started");

if (ctx.isServer)
_networkManager.StartServer();
if (ctx.isClient)
_networkManager.StartClient();
if (!_autoStartRegression)
{
Assert.IsTrue(_networkManager.isOffline);
Assert.AreEqual(_networkManager.clientState, ConnectionState.Disconnected, "Client is not connected");
Assert.AreEqual(_networkManager.serverState, ConnectionState.Disconnected, "Server is not started");

if (ctx.isServer)
_networkManager.StartServer();
if (ctx.isClient)
_networkManager.StartClient();
}

float connectDeadline = Time.realtimeSinceStartup + _connectionTimeout;
int connectRetries = 0;
Expand Down
Loading
Loading