diff --git a/.github/workflows/dispatch.yml b/.github/workflows/dispatch.yml new file mode 100644 index 0000000..6818b75 --- /dev/null +++ b/.github/workflows/dispatch.yml @@ -0,0 +1,25 @@ +name: Dispatch + +on: + pull_request: + push: + branches: [main] + +jobs: + dispatch: + name: Protocol dispatch (.NET, no Unity license) + runs-on: ubuntu-24.04 + timeout-minutes: 10 + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' + + - name: Restore + run: dotnet restore Tests/AsobiCore.NET/Asobi.Core.Tests.csproj + + - name: Test + run: dotnet test Tests/AsobiCore.NET/Asobi.Core.Tests.csproj --no-restore --logger "console;verbosity=normal" diff --git a/.gitignore b/.gitignore index 50b386b..75fea1d 100644 --- a/.gitignore +++ b/.gitignore @@ -6,8 +6,10 @@ [Ll]ogs/ [Uu]ser[Ss]ettings/ *.csproj +!Tests/AsobiCore.NET/Asobi.Core.Tests.csproj *.sln *.suo +bin/ *.tmp *.user *.userprefs diff --git a/Runtime/AssemblyInfo.cs b/Runtime/AssemblyInfo.cs new file mode 100644 index 0000000..b17b883 --- /dev/null +++ b/Runtime/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Asobi.Tests")] diff --git a/Runtime/AssemblyInfo.cs.meta b/Runtime/AssemblyInfo.cs.meta new file mode 100644 index 0000000..26b5cc9 --- /dev/null +++ b/Runtime/AssemblyInfo.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 173241c20e37404a92f273bc8ffb5c0f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/WebSocket/AsobiDispatcher.cs b/Runtime/WebSocket/AsobiDispatcher.cs new file mode 100644 index 0000000..a6a19db --- /dev/null +++ b/Runtime/WebSocket/AsobiDispatcher.cs @@ -0,0 +1,252 @@ +using System; + +namespace Asobi +{ + public class AsobiDispatcher + { + public event Action OnConnected; + public event Action OnDisconnected; + public event Action OnMatchState; + public event Action OnMatchEvent; + public event Action OnChatMessage; + public event Action OnNotification; + public event Action OnMatchmakerMatched; + public event Action OnVoteStart; + public event Action OnVoteTally; + public event Action OnVoteResult; + public event Action OnVoteVetoed; + public event Action OnWorldTick; + public event Action OnWorldTerrain; + public event Action OnWorldJoined; + public event Action OnWorldLeft; + public event Action OnWorldEvent; + public event Action OnDmMessage; + public event Action OnDmSent; + public event Action OnPresenceUpdated; + public event Action OnMatchJoined; + public event Action OnMatchLeft; + public event Action OnChatJoined; + public event Action OnChatLeft; + public event Action OnMatchmakerQueued; + public event Action OnMatchmakerRemoved; + public event Action OnVoteCastOk; + public event Action OnVoteVetoOk; + public event Action OnError; + public event Action OnHeartbeat; + public event Action OnMatchFinished; + public event Action OnMatchmakerExpired; + public event Action OnMatchmakerFailed; + public event Action OnWorldFinished; + public event Action OnWorldList; + public event Action OnWorldPhaseChanged; + + protected void RaiseDisconnected(string reason) => OnDisconnected?.Invoke(reason); + + protected internal virtual void OnPendingResponse(string cid, string type, string raw) { } + + internal void HandleMessage(string raw) + { + var env = ProtocolEnvelope.Parse(raw); + if (env.Type == null) return; + + if (!string.IsNullOrEmpty(env.Cid)) + OnPendingResponse(env.Cid, env.Type, raw); + + switch (env.Type) + { + case "session.connected": + OnConnected?.Invoke(); + break; + case "match.state": + OnMatchState?.Invoke(raw); + break; + case "chat.message": + OnChatMessage?.Invoke(raw); + break; + case "notification.new": + OnNotification?.Invoke(raw); + break; + // TODO deprecate: server only emits "match.matched". The + // "matchmaker.matched" alias is kept defensively against + // historical drift; remove in a future major version. + case "matchmaker.matched": + case "match.matched": + OnMatchmakerMatched?.Invoke(raw); + break; + case "match.finished": + OnMatchFinished?.Invoke(raw); + break; + case "match.matchmaker_expired": + OnMatchmakerExpired?.Invoke(raw); + break; + case "match.matchmaker_failed": + OnMatchmakerFailed?.Invoke(raw); + break; + case "match.vote_start": + OnVoteStart?.Invoke(raw); + break; + case "match.vote_tally": + OnVoteTally?.Invoke(raw); + break; + case "match.vote_result": + OnVoteResult?.Invoke(raw); + break; + case "match.vote_vetoed": + OnVoteVetoed?.Invoke(raw); + break; + case "world.tick": + OnWorldTick?.Invoke(raw); + break; + case "world.terrain": + OnWorldTerrain?.Invoke(raw); + break; + case "world.list": + OnWorldList?.Invoke(raw); + break; + case "world.joined": + OnWorldJoined?.Invoke(raw); + break; + case "world.left": + OnWorldLeft?.Invoke(raw); + break; + case "world.phase_changed": + OnWorldPhaseChanged?.Invoke(raw); + break; + case "world.finished": + OnWorldFinished?.Invoke(raw); + break; + case "match.joined": + OnMatchJoined?.Invoke(raw); + break; + case "match.left": + OnMatchLeft?.Invoke(raw); + break; + case "chat.joined": + OnChatJoined?.Invoke(raw); + break; + case "chat.left": + OnChatLeft?.Invoke(raw); + break; + case "matchmaker.queued": + OnMatchmakerQueued?.Invoke(raw); + break; + case "matchmaker.removed": + OnMatchmakerRemoved?.Invoke(raw); + break; + case "vote.cast_ok": + OnVoteCastOk?.Invoke(raw); + break; + case "vote.veto_ok": + OnVoteVetoOk?.Invoke(raw); + break; + case "dm.message": + OnDmMessage?.Invoke(raw); + break; + case "dm.sent": + OnDmSent?.Invoke(raw); + break; + case "presence.updated": + OnPresenceUpdated?.Invoke(raw); + break; + case "session.heartbeat": + OnHeartbeat?.Invoke(raw); + break; + case "error": + OnError?.Invoke(raw); + break; + default: + if (env.Type.StartsWith("match.")) + { + var eventName = env.Type.Substring(6); + OnMatchEvent?.Invoke(eventName, raw); + } + else if (env.Type.StartsWith("world.")) + { + var eventName = env.Type.Substring(6); + OnWorldEvent?.Invoke(eventName, raw); + } + break; + } + } + } + + internal readonly struct ProtocolEnvelope + { + public readonly string Type; + public readonly string Cid; + + ProtocolEnvelope(string type, string cid) { Type = type; Cid = cid; } + + public static ProtocolEnvelope Parse(string raw) + { + if (string.IsNullOrEmpty(raw)) return default; + return new ProtocolEnvelope(ReadStringField(raw, "type"), ReadStringField(raw, "cid")); + } + + static string ReadStringField(string json, string field) + { + var key = "\"" + field + "\""; + int i = 0; + while (true) + { + int k = json.IndexOf(key, i, StringComparison.Ordinal); + if (k < 0) return null; + int after = k + key.Length; + while (after < json.Length && (json[after] == ' ' || json[after] == '\t' || json[after] == '\n' || json[after] == '\r')) + after++; + if (after >= json.Length || json[after] != ':') + { + i = k + key.Length; + continue; + } + if (!IsKeyPosition(json, k)) + { + i = k + key.Length; + continue; + } + after++; + while (after < json.Length && (json[after] == ' ' || json[after] == '\t' || json[after] == '\n' || json[after] == '\r')) + after++; + if (after >= json.Length || json[after] != '"') return null; + int start = after + 1; + var sb = new System.Text.StringBuilder(); + for (int p = start; p < json.Length; p++) + { + char c = json[p]; + if (c == '\\' && p + 1 < json.Length) + { + char n = json[p + 1]; + switch (n) + { + case '"': sb.Append('"'); break; + case '\\': sb.Append('\\'); break; + case '/': sb.Append('/'); break; + case 'n': sb.Append('\n'); break; + case 't': sb.Append('\t'); break; + case 'r': sb.Append('\r'); break; + case 'b': sb.Append('\b'); break; + case 'f': sb.Append('\f'); break; + default: sb.Append(n); break; + } + p++; + continue; + } + if (c == '"') return sb.ToString(); + sb.Append(c); + } + return null; + } + } + + static bool IsKeyPosition(string json, int quoteIdx) + { + for (int j = quoteIdx - 1; j >= 0; j--) + { + char c = json[j]; + if (c == ' ' || c == '\t' || c == '\n' || c == '\r') continue; + return c == '{' || c == ','; + } + return true; + } + } +} diff --git a/Runtime/WebSocket/AsobiDispatcher.cs.meta b/Runtime/WebSocket/AsobiDispatcher.cs.meta new file mode 100644 index 0000000..3bbe7df --- /dev/null +++ b/Runtime/WebSocket/AsobiDispatcher.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7f5bc952901a46a39653ca4fce84698b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/WebSocket/AsobiRealtime.cs b/Runtime/WebSocket/AsobiRealtime.cs index c3f85ab..962e352 100644 --- a/Runtime/WebSocket/AsobiRealtime.cs +++ b/Runtime/WebSocket/AsobiRealtime.cs @@ -8,7 +8,7 @@ namespace Asobi { - public class AsobiRealtime : IDisposable + public class AsobiRealtime : AsobiDispatcher, IDisposable { readonly AsobiClient _client; ClientWebSocket _ws; @@ -18,37 +18,12 @@ public class AsobiRealtime : IDisposable public bool IsConnected => _ws?.State == WebSocketState.Open; - public event Action OnConnected; - public event Action OnDisconnected; - public event Action OnMatchState; - public event Action OnMatchEvent; - public event Action OnChatMessage; - public event Action OnNotification; - public event Action OnMatchmakerMatched; - public event Action OnVoteStart; - public event Action OnVoteTally; - public event Action OnVoteResult; - public event Action OnVoteVetoed; - public event Action OnWorldTick; - public event Action OnWorldTerrain; - public event Action OnWorldJoined; - public event Action OnWorldLeft; - public event Action OnWorldEvent; - public event Action OnDmMessage; - public event Action OnDmSent; - public event Action OnPresenceUpdated; - public event Action OnMatchJoined; - public event Action OnMatchLeft; - public event Action OnChatJoined; - public event Action OnChatLeft; - public event Action OnMatchmakerQueued; - public event Action OnMatchmakerRemoved; - public event Action OnVoteCastOk; - public event Action OnVoteVetoOk; - public event Action OnError; - internal AsobiRealtime(AsobiClient client) => _client = client; + // Test-only: construct without a client/WebSocket so dispatch logic + // can be exercised in isolation. + internal AsobiRealtime() { } + public async Task ConnectAsync() { if (IsConnected) return; @@ -259,7 +234,7 @@ async Task ReceiveLoop() result = await _ws.ReceiveAsync(new ArraySegment(buffer), _cts.Token); if (result.MessageType == WebSocketMessageType.Close) { - OnDisconnected?.Invoke(result.CloseStatusDescription); + RaiseDisconnected(result.CloseStatusDescription); return; } sb.Append(Encoding.UTF8.GetString(buffer, 0, result.Count)); @@ -271,118 +246,17 @@ async Task ReceiveLoop() catch (OperationCanceledException) { } catch (WebSocketException ex) { - OnDisconnected?.Invoke(ex.Message); + RaiseDisconnected(ex.Message); } } - void HandleMessage(string raw) + protected internal override void OnPendingResponse(string cid, string type, string raw) { - var msg = JsonUtility.FromJson(raw); - if (msg == null) return; - - // Handle request/response via cid - if (!string.IsNullOrEmpty(msg.cid) && _pending.TryRemove(msg.cid, out var tcs)) - { - if (msg.type == "error") - tcs.SetException(new AsobiException(-1, msg.payload)); - else - tcs.SetResult(raw); - } - - // Dispatch events - switch (msg.type) - { - case "session.connected": - OnConnected?.Invoke(); - break; - case "match.state": - OnMatchState?.Invoke(raw); - break; - case "chat.message": - OnChatMessage?.Invoke(raw); - break; - case "notification.new": - OnNotification?.Invoke(raw); - break; - case "matchmaker.matched": - case "match.matched": - OnMatchmakerMatched?.Invoke(raw); - break; - case "match.vote_start": - OnVoteStart?.Invoke(raw); - break; - case "match.vote_tally": - OnVoteTally?.Invoke(raw); - break; - case "match.vote_result": - OnVoteResult?.Invoke(raw); - break; - case "match.vote_vetoed": - OnVoteVetoed?.Invoke(raw); - break; - case "world.tick": - OnWorldTick?.Invoke(raw); - break; - case "world.terrain": - OnWorldTerrain?.Invoke(raw); - break; - case "world.joined": - OnWorldJoined?.Invoke(raw); - break; - case "world.left": - OnWorldLeft?.Invoke(raw); - break; - case "match.joined": - OnMatchJoined?.Invoke(raw); - break; - case "match.left": - OnMatchLeft?.Invoke(raw); - break; - case "chat.joined": - OnChatJoined?.Invoke(raw); - break; - case "chat.left": - OnChatLeft?.Invoke(raw); - break; - case "matchmaker.queued": - OnMatchmakerQueued?.Invoke(raw); - break; - case "matchmaker.removed": - OnMatchmakerRemoved?.Invoke(raw); - break; - case "vote.cast_ok": - OnVoteCastOk?.Invoke(raw); - break; - case "vote.veto_ok": - OnVoteVetoOk?.Invoke(raw); - break; - case "dm.message": - OnDmMessage?.Invoke(raw); - break; - case "dm.sent": - OnDmSent?.Invoke(raw); - break; - case "presence.updated": - OnPresenceUpdated?.Invoke(raw); - break; - case "session.heartbeat": - break; - case "error": - OnError?.Invoke(raw); - break; - default: - if (msg.type != null && msg.type.StartsWith("match.")) - { - var eventName = msg.type.Substring(6); - OnMatchEvent?.Invoke(eventName, raw); - } - else if (msg.type != null && msg.type.StartsWith("world.")) - { - var eventName = msg.type.Substring(6); - OnWorldEvent?.Invoke(eventName, raw); - } - break; - } + if (!_pending.TryRemove(cid, out var tcs)) return; + if (type == "error") + tcs.SetException(new AsobiException(-1, raw)); + else + tcs.SetResult(raw); } public void Dispose() diff --git a/Tests/AsobiCore.NET.meta b/Tests/AsobiCore.NET.meta new file mode 100644 index 0000000..86e8366 --- /dev/null +++ b/Tests/AsobiCore.NET.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: dfc6606b28a148eda6ca29f8d202008f +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/AsobiCore.NET/Asobi.Core.Net.Tests.asmdef b/Tests/AsobiCore.NET/Asobi.Core.Net.Tests.asmdef new file mode 100644 index 0000000..87bf02c --- /dev/null +++ b/Tests/AsobiCore.NET/Asobi.Core.Net.Tests.asmdef @@ -0,0 +1,18 @@ +{ + "name": "Asobi.Core.Net.Tests", + "rootNamespace": "Asobi.Tests", + "references": [], + "includePlatforms": [ + "Editor" + ], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": false, + "defineConstraints": [ + "ASOBI_NET_STANDALONE_BUILD" + ], + "versionDefines": [], + "noEngineReferences": true +} diff --git a/Tests/AsobiCore.NET/Asobi.Core.Net.Tests.asmdef.meta b/Tests/AsobiCore.NET/Asobi.Core.Net.Tests.asmdef.meta new file mode 100644 index 0000000..34f5cb7 --- /dev/null +++ b/Tests/AsobiCore.NET/Asobi.Core.Net.Tests.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 62ff6b8e7bfa4e6bba0d06fe51be8fc6 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/AsobiCore.NET/Asobi.Core.Tests.csproj b/Tests/AsobiCore.NET/Asobi.Core.Tests.csproj new file mode 100644 index 0000000..b32bcd5 --- /dev/null +++ b/Tests/AsobiCore.NET/Asobi.Core.Tests.csproj @@ -0,0 +1,28 @@ + + + + net8.0 + latest + disable + false + true + Asobi.Tests + Asobi.Tests + false + + + + + + + + + + + + + + + + + diff --git a/Tests/AsobiCore.NET/DispatcherTests.cs b/Tests/AsobiCore.NET/DispatcherTests.cs new file mode 100644 index 0000000..b716161 --- /dev/null +++ b/Tests/AsobiCore.NET/DispatcherTests.cs @@ -0,0 +1,159 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using NUnit.Framework; + +namespace Asobi.Tests +{ + public class DispatcherTests + { + static readonly Dictionary Expected = new() + { + { "error", nameof(AsobiDispatcher.OnError) }, + { "session.connected", nameof(AsobiDispatcher.OnConnected) }, + { "session.heartbeat", nameof(AsobiDispatcher.OnHeartbeat) }, + { "match.state", nameof(AsobiDispatcher.OnMatchState) }, + { "match.matched", nameof(AsobiDispatcher.OnMatchmakerMatched) }, + { "match.joined", nameof(AsobiDispatcher.OnMatchJoined) }, + { "match.left", nameof(AsobiDispatcher.OnMatchLeft) }, + { "match.finished", nameof(AsobiDispatcher.OnMatchFinished) }, + { "match.matchmaker_expired", nameof(AsobiDispatcher.OnMatchmakerExpired) }, + { "match.matchmaker_failed", nameof(AsobiDispatcher.OnMatchmakerFailed) }, + { "match.vote_start", nameof(AsobiDispatcher.OnVoteStart) }, + { "match.vote_tally", nameof(AsobiDispatcher.OnVoteTally) }, + { "match.vote_result", nameof(AsobiDispatcher.OnVoteResult) }, + { "match.vote_vetoed", nameof(AsobiDispatcher.OnVoteVetoed) }, + { "matchmaker.queued", nameof(AsobiDispatcher.OnMatchmakerQueued) }, + { "matchmaker.removed", nameof(AsobiDispatcher.OnMatchmakerRemoved) }, + { "chat.joined", nameof(AsobiDispatcher.OnChatJoined) }, + { "chat.left", nameof(AsobiDispatcher.OnChatLeft) }, + { "chat.message", nameof(AsobiDispatcher.OnChatMessage) }, + { "dm.sent", nameof(AsobiDispatcher.OnDmSent) }, + { "dm.message", nameof(AsobiDispatcher.OnDmMessage) }, + { "presence.updated", nameof(AsobiDispatcher.OnPresenceUpdated) }, + { "notification.new", nameof(AsobiDispatcher.OnNotification) }, + { "vote.cast_ok", nameof(AsobiDispatcher.OnVoteCastOk) }, + { "vote.veto_ok", nameof(AsobiDispatcher.OnVoteVetoOk) }, + { "world.tick", nameof(AsobiDispatcher.OnWorldTick) }, + { "world.terrain", nameof(AsobiDispatcher.OnWorldTerrain) }, + { "world.list", nameof(AsobiDispatcher.OnWorldList) }, + { "world.joined", nameof(AsobiDispatcher.OnWorldJoined) }, + { "world.left", nameof(AsobiDispatcher.OnWorldLeft) }, + { "world.phase_changed", nameof(AsobiDispatcher.OnWorldPhaseChanged) }, + { "world.finished", nameof(AsobiDispatcher.OnWorldFinished) }, + }; + + static IEnumerable FixtureCases() + { + foreach (var kv in Expected) + yield return new TestCaseData(kv.Key, kv.Value).SetName($"Dispatches_{kv.Key}"); + } + + [Test, TestCaseSource(nameof(FixtureCases))] + public void DispatchesFixtureToExpectedEvent(string wireType, string eventName) + { + var raw = LoadFixture(wireType); + Assert.That(raw, Is.Not.Null.And.Not.Empty, + $"fixture for '{wireType}' missing under Fixtures/"); + + var dispatcher = new AsobiDispatcher(); + var fired = false; + Subscribe(dispatcher, eventName, () => fired = true); + + dispatcher.HandleMessage(raw); + + Assert.That(fired, Is.True, + $"'{wireType}' did not fire {eventName}"); + } + + [Test] + public void EveryFixtureHasExpectedMapping() + { + var fixtures = LoadAllFixtureNames(); + Assert.That(fixtures.Count, Is.GreaterThan(0), + "no fixtures loaded from Fixtures/"); + + var unmapped = fixtures + .Where(name => !Expected.ContainsKey(name)) + .ToList(); + + Assert.That(unmapped, Is.Empty, + "fixtures with no Expected mapping (add a dispatch case + entry): " + + string.Join(", ", unmapped)); + } + + [Test] + public void EveryExpectedHasFixture() + { + var fixtureNames = LoadAllFixtureNames().ToHashSet(); + + var stale = Expected.Keys + .Where(t => !fixtureNames.Contains(t)) + .ToList(); + + Assert.That(stale, Is.Empty, + "Expected entries with no fixture (stale or missing fixture): " + + string.Join(", ", stale)); + } + + [Test] + public void MatchmakerMatchedAliasesMatchMatched() + { + var raw = "{\"type\":\"matchmaker.matched\",\"payload\":{\"match_id\":\"m1\"}}"; + + var dispatcher = new AsobiDispatcher(); + var fired = false; + dispatcher.OnMatchmakerMatched += _ => fired = true; + dispatcher.HandleMessage(raw); + + Assert.That(fired, Is.True, + "matchmaker.matched alias should still dispatch to OnMatchmakerMatched"); + } + + // ---- helpers ---- + + static string FixtureDir => + Path.Combine(AppContext.BaseDirectory, "Fixtures"); + + static string LoadFixture(string wireType) + { + var path = Path.Combine(FixtureDir, wireType + ".json"); + return File.Exists(path) ? File.ReadAllText(path) : null; + } + + static List LoadAllFixtureNames() + { + if (!Directory.Exists(FixtureDir)) return new List(); + return Directory.GetFiles(FixtureDir, "*.json") + .Select(Path.GetFileNameWithoutExtension) + .ToList(); + } + + static void Subscribe(AsobiDispatcher dispatcher, string eventName, Action onFire) + { + var ev = typeof(AsobiDispatcher).GetEvent(eventName); + Assert.That(ev, Is.Not.Null, $"AsobiDispatcher has no event named {eventName}"); + + var handlerType = ev.EventHandlerType; + + Delegate handler; + if (handlerType == typeof(Action)) + { + handler = onFire; + } + else if (handlerType == typeof(Action)) + { + Action wrapped = _ => onFire(); + handler = wrapped; + } + else + { + throw new InvalidOperationException( + $"event {eventName} has unsupported delegate type {handlerType}"); + } + + ev.AddEventHandler(dispatcher, handler); + } + } +} diff --git a/Tests/AsobiCore.NET/DispatcherTests.cs.meta b/Tests/AsobiCore.NET/DispatcherTests.cs.meta new file mode 100644 index 0000000..51a0f5e --- /dev/null +++ b/Tests/AsobiCore.NET/DispatcherTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c4221ccc407a48568711d6bad2ab029b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/AsobiCore.NET/README.md b/Tests/AsobiCore.NET/README.md new file mode 100644 index 0000000..184faeb --- /dev/null +++ b/Tests/AsobiCore.NET/README.md @@ -0,0 +1,28 @@ +# Asobi.Core .NET dispatch tests + +Standalone .NET test project for the engine-agnostic protocol dispatch +layer. Mirrors `AsobiCore` in `asobi-unreal`: the same code path Unity +runs is exercised here without any Unity license, so dispatch validation +runs on stock `ubuntu-24.04` in CI. + +The single source file `Runtime/WebSocket/AsobiDispatcher.cs` is shared +via `` in the csproj — +no copies. Fixtures under `Tests/Runtime/Resources/Fixtures/` are +referenced via `` and loaded +at test time from `AppContext.BaseDirectory/Fixtures/`. + +## Run + +```sh +dotnet test Tests/AsobiCore.NET/Asobi.Core.Tests.csproj +``` + +## What it covers + +- 32 fixture cases from `asobi/priv/protocol/fixtures` — every wire + envelope the server emits is fed through `AsobiDispatcher.HandleMessage` + and the matching event must fire. +- `EveryFixtureHasExpectedMapping` / `EveryExpectedHasFixture` pin the + bijection between the fixture set and the dispatcher's switch cases. +- `MatchmakerMatchedAliasesMatchMatched` pins the historical + `matchmaker.matched` alias. diff --git a/Tests/Runtime/DispatchTests.cs b/Tests/Runtime/DispatchTests.cs new file mode 100644 index 0000000..7572051 --- /dev/null +++ b/Tests/Runtime/DispatchTests.cs @@ -0,0 +1,180 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using UnityEngine; + +namespace Asobi.Tests +{ + /// + /// Per-SDK protocol dispatch unit test. + /// + /// Feeds every canonical server-emitted message envelope through the + /// SDK's WebSocket message handler and asserts the right SDK event + /// fires. Catches doc-vs-server drift (silent failures) before any + /// user reports a missed callback. + /// + /// Fixtures live under Tests/Runtime/Resources/Fixtures/*.json and are + /// vendored from asobi/priv/protocol/fixtures (the canonical corpus). + /// + public class DispatchTests + { + // Maps server wire `type` -> the event name on AsobiRealtime that + // should fire for that fixture. Must stay in sync with the asobi + // protocol fixture corpus and the dispatch switch in + // AsobiRealtime.HandleMessage. + static readonly Dictionary Expected = new() + { + { "error", nameof(AsobiRealtime.OnError) }, + { "session.connected", nameof(AsobiRealtime.OnConnected) }, + { "session.heartbeat", nameof(AsobiRealtime.OnHeartbeat) }, + { "match.state", nameof(AsobiRealtime.OnMatchState) }, + { "match.matched", nameof(AsobiRealtime.OnMatchmakerMatched) }, + { "match.joined", nameof(AsobiRealtime.OnMatchJoined) }, + { "match.left", nameof(AsobiRealtime.OnMatchLeft) }, + { "match.finished", nameof(AsobiRealtime.OnMatchFinished) }, + { "match.matchmaker_expired", nameof(AsobiRealtime.OnMatchmakerExpired) }, + { "match.matchmaker_failed", nameof(AsobiRealtime.OnMatchmakerFailed) }, + { "match.vote_start", nameof(AsobiRealtime.OnVoteStart) }, + { "match.vote_tally", nameof(AsobiRealtime.OnVoteTally) }, + { "match.vote_result", nameof(AsobiRealtime.OnVoteResult) }, + { "match.vote_vetoed", nameof(AsobiRealtime.OnVoteVetoed) }, + { "matchmaker.queued", nameof(AsobiRealtime.OnMatchmakerQueued) }, + { "matchmaker.removed", nameof(AsobiRealtime.OnMatchmakerRemoved) }, + { "chat.joined", nameof(AsobiRealtime.OnChatJoined) }, + { "chat.left", nameof(AsobiRealtime.OnChatLeft) }, + { "chat.message", nameof(AsobiRealtime.OnChatMessage) }, + { "dm.sent", nameof(AsobiRealtime.OnDmSent) }, + { "dm.message", nameof(AsobiRealtime.OnDmMessage) }, + { "presence.updated", nameof(AsobiRealtime.OnPresenceUpdated) }, + { "notification.new", nameof(AsobiRealtime.OnNotification) }, + { "vote.cast_ok", nameof(AsobiRealtime.OnVoteCastOk) }, + { "vote.veto_ok", nameof(AsobiRealtime.OnVoteVetoOk) }, + { "world.tick", nameof(AsobiRealtime.OnWorldTick) }, + { "world.terrain", nameof(AsobiRealtime.OnWorldTerrain) }, + { "world.list", nameof(AsobiRealtime.OnWorldList) }, + { "world.joined", nameof(AsobiRealtime.OnWorldJoined) }, + { "world.left", nameof(AsobiRealtime.OnWorldLeft) }, + { "world.phase_changed", nameof(AsobiRealtime.OnWorldPhaseChanged) }, + { "world.finished", nameof(AsobiRealtime.OnWorldFinished) }, + }; + + static IEnumerable FixtureCases() + { + foreach (var kv in Expected) + { + yield return new TestCaseData(kv.Key, kv.Value).SetName($"Dispatches_{kv.Key}"); + } + } + + [Test, TestCaseSource(nameof(FixtureCases))] + public void DispatchesFixtureToExpectedEvent(string wireType, string eventName) + { + var raw = LoadFixture(wireType); + Assert.That(raw, Is.Not.Null.And.Not.Empty, + $"fixture for '{wireType}' missing under Resources/Fixtures/"); + + var realtime = new AsobiRealtime(); + var fired = false; + Subscribe(realtime, eventName, () => fired = true); + + realtime.HandleMessage(raw); + + Assert.That(fired, Is.True, + $"'{wireType}' did not fire {eventName}"); + } + + [Test] + public void EveryFixtureHasExpectedMapping() + { + var fixtures = Resources.LoadAll("Fixtures"); + Assert.That(fixtures.Length, Is.GreaterThan(0), + "no fixtures loaded from Resources/Fixtures/"); + + var unmapped = fixtures + .Select(f => f.name) + .Where(name => !Expected.ContainsKey(name)) + .ToList(); + + Assert.That(unmapped, Is.Empty, + "fixtures with no Expected mapping (add a dispatch case + entry): " + + string.Join(", ", unmapped)); + } + + [Test] + public void EveryExpectedHasFixture() + { + var fixtureNames = Resources.LoadAll("Fixtures") + .Select(f => f.name) + .ToHashSet(); + + var stale = Expected.Keys + .Where(t => !fixtureNames.Contains(t)) + .ToList(); + + Assert.That(stale, Is.Empty, + "Expected entries with no fixture (stale or missing fixture): " + + string.Join(", ", stale)); + } + + [Test] + public void MatchmakerMatchedAliasesMatchMatched() + { + // Server only emits "match.matched"; "matchmaker.matched" is a + // historical alias kept defensively. This test pins that alias + // until it's removed in a future major. + var raw = "{\"type\":\"matchmaker.matched\",\"payload\":{\"match_id\":\"m1\"}}"; + + var realtime = new AsobiRealtime(); + var fired = false; + realtime.OnMatchmakerMatched += _ => fired = true; + realtime.HandleMessage(raw); + + Assert.That(fired, Is.True, + "matchmaker.matched alias should still dispatch to OnMatchmakerMatched"); + } + + // ---- helpers ---- + + static Dictionary _fixtureCache; + + static string LoadFixture(string wireType) + { + if (_fixtureCache == null) + { + _fixtureCache = Resources.LoadAll("Fixtures") + .ToDictionary(t => t.name, t => t.text); + } + return _fixtureCache.TryGetValue(wireType, out var raw) ? raw : null; + } + + static void Subscribe(AsobiRealtime realtime, string eventName, Action onFire) + { + // Reflectively attach a handler to the named event so the test + // stays data-driven. Supports the two delegate shapes used by + // AsobiRealtime: Action and Action. + var ev = typeof(AsobiRealtime).GetEvent(eventName); + Assert.That(ev, Is.Not.Null, $"AsobiRealtime has no event named {eventName}"); + + var handlerType = ev.EventHandlerType; + + Delegate handler; + if (handlerType == typeof(Action)) + { + handler = onFire; + } + else if (handlerType == typeof(Action)) + { + Action wrapped = _ => onFire(); + handler = wrapped; + } + else + { + throw new InvalidOperationException( + $"event {eventName} has unsupported delegate type {handlerType}"); + } + + ev.AddEventHandler(realtime, handler); + } + } +} diff --git a/Tests/Runtime/DispatchTests.cs.meta b/Tests/Runtime/DispatchTests.cs.meta new file mode 100644 index 0000000..9bfc076 --- /dev/null +++ b/Tests/Runtime/DispatchTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 834d7e72a9e042d3954745166c9c5a1a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Runtime/README.md b/Tests/Runtime/README.md index 2837423..370e511 100644 --- a/Tests/Runtime/README.md +++ b/Tests/Runtime/README.md @@ -1,4 +1,12 @@ -# Unity smoke test +# Unity tests + +## DispatchTests + +`DispatchTests.cs` is a pure-unit dispatch test. Feeds every canonical server-emitted message envelope (vendored under `Resources/Fixtures/` from `asobi/priv/protocol/fixtures`) through `AsobiRealtime.HandleMessage` and asserts the matching event fires. Catches doc-vs-server drift before any user reports a silent failure. + +It runs in PlayMode (where the `Tests/Runtime` asmdef lives) but uses `[Test]` (not `[UnityTest]`) and does not require a backend or a scene. + +## SmokeTest `SmokeTest.cs` exercises the 3 canonical scenarios (auth + WS, matchmaker → match.matched, input → state) against [asobi-test-harness](https://github.com/widgrensit/asobi-test-harness). It's a Unity Test Framework `UnityTest`. diff --git a/Tests/Runtime/Resources.meta b/Tests/Runtime/Resources.meta new file mode 100644 index 0000000..611ce0a --- /dev/null +++ b/Tests/Runtime/Resources.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 54b0d817939243468b45c028f72f0f10 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Runtime/Resources/Fixtures.meta b/Tests/Runtime/Resources/Fixtures.meta new file mode 100644 index 0000000..f27ff4d --- /dev/null +++ b/Tests/Runtime/Resources/Fixtures.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 8f0819f3494749e1b4153d4256a38741 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Runtime/Resources/Fixtures/chat.joined.json b/Tests/Runtime/Resources/Fixtures/chat.joined.json new file mode 100644 index 0000000..b3a1f77 --- /dev/null +++ b/Tests/Runtime/Resources/Fixtures/chat.joined.json @@ -0,0 +1 @@ +{"type":"chat.joined","cid":"7","payload":{"channel_id":"01j8x000000000000000000005"}} diff --git a/Tests/Runtime/Resources/Fixtures/chat.left.json b/Tests/Runtime/Resources/Fixtures/chat.left.json new file mode 100644 index 0000000..ef05147 --- /dev/null +++ b/Tests/Runtime/Resources/Fixtures/chat.left.json @@ -0,0 +1 @@ +{"type":"chat.left","cid":"8","payload":{"channel_id":"01j8x000000000000000000005"}} diff --git a/Tests/Runtime/Resources/Fixtures/chat.message.json b/Tests/Runtime/Resources/Fixtures/chat.message.json new file mode 100644 index 0000000..edb07b5 --- /dev/null +++ b/Tests/Runtime/Resources/Fixtures/chat.message.json @@ -0,0 +1 @@ +{"type":"chat.message","payload":{"channel_id":"01j8x000000000000000000005","sender_id":"01j8x000000000000000000000","content":"hello","ts":1730000000000}} diff --git a/Tests/Runtime/Resources/Fixtures/dm.message.json b/Tests/Runtime/Resources/Fixtures/dm.message.json new file mode 100644 index 0000000..7217128 --- /dev/null +++ b/Tests/Runtime/Resources/Fixtures/dm.message.json @@ -0,0 +1 @@ +{"type":"dm.message","payload":{"sender_id":"01j8x000000000000000000002","content":"hi","ts":1730000000000}} diff --git a/Tests/Runtime/Resources/Fixtures/dm.sent.json b/Tests/Runtime/Resources/Fixtures/dm.sent.json new file mode 100644 index 0000000..84071d2 --- /dev/null +++ b/Tests/Runtime/Resources/Fixtures/dm.sent.json @@ -0,0 +1 @@ +{"type":"dm.sent","cid":"9","payload":{"recipient_id":"01j8x000000000000000000002","ts":1730000000000}} diff --git a/Tests/Runtime/Resources/Fixtures/error.json b/Tests/Runtime/Resources/Fixtures/error.json new file mode 100644 index 0000000..aa8b5cf --- /dev/null +++ b/Tests/Runtime/Resources/Fixtures/error.json @@ -0,0 +1 @@ +{"type":"error","payload":{"reason":"invalid_message"}} diff --git a/Tests/Runtime/Resources/Fixtures/match.finished.json b/Tests/Runtime/Resources/Fixtures/match.finished.json new file mode 100644 index 0000000..15ad8dd --- /dev/null +++ b/Tests/Runtime/Resources/Fixtures/match.finished.json @@ -0,0 +1 @@ +{"type":"match.finished","payload":{"match_id":"01j8x000000000000000000001","result":{"winner":"01j8x000000000000000000000"}}} diff --git a/Tests/Runtime/Resources/Fixtures/match.joined.json b/Tests/Runtime/Resources/Fixtures/match.joined.json new file mode 100644 index 0000000..a9b7755 --- /dev/null +++ b/Tests/Runtime/Resources/Fixtures/match.joined.json @@ -0,0 +1 @@ +{"type":"match.joined","cid":"3","payload":{"match_id":"01j8x000000000000000000001","mode":"demo","players":["01j8x000000000000000000000"]}} diff --git a/Tests/Runtime/Resources/Fixtures/match.left.json b/Tests/Runtime/Resources/Fixtures/match.left.json new file mode 100644 index 0000000..9466845 --- /dev/null +++ b/Tests/Runtime/Resources/Fixtures/match.left.json @@ -0,0 +1 @@ +{"type":"match.left","cid":"4","payload":{"success":true}} diff --git a/Tests/Runtime/Resources/Fixtures/match.matched.json b/Tests/Runtime/Resources/Fixtures/match.matched.json new file mode 100644 index 0000000..6750684 --- /dev/null +++ b/Tests/Runtime/Resources/Fixtures/match.matched.json @@ -0,0 +1 @@ +{"type":"match.matched","payload":{"match_id":"01j8x000000000000000000001","mode":"demo","players":["01j8x000000000000000000000","01j8x000000000000000000002"]}} diff --git a/Tests/Runtime/Resources/Fixtures/match.matchmaker_expired.json b/Tests/Runtime/Resources/Fixtures/match.matchmaker_expired.json new file mode 100644 index 0000000..4e25f23 --- /dev/null +++ b/Tests/Runtime/Resources/Fixtures/match.matchmaker_expired.json @@ -0,0 +1 @@ +{"type":"match.matchmaker_expired","payload":{"ticket_id":"01j8x000000000000000000003"}} diff --git a/Tests/Runtime/Resources/Fixtures/match.matchmaker_failed.json b/Tests/Runtime/Resources/Fixtures/match.matchmaker_failed.json new file mode 100644 index 0000000..4ba71c7 --- /dev/null +++ b/Tests/Runtime/Resources/Fixtures/match.matchmaker_failed.json @@ -0,0 +1 @@ +{"type":"match.matchmaker_failed","payload":{"reason":"no_game_module"}} diff --git a/Tests/Runtime/Resources/Fixtures/match.state.json b/Tests/Runtime/Resources/Fixtures/match.state.json new file mode 100644 index 0000000..b7fd103 --- /dev/null +++ b/Tests/Runtime/Resources/Fixtures/match.state.json @@ -0,0 +1 @@ +{"type":"match.state","payload":{"tick":7,"players":{"01j8x000000000000000000000":{"x":120,"y":80}}}} diff --git a/Tests/Runtime/Resources/Fixtures/match.vote_result.json b/Tests/Runtime/Resources/Fixtures/match.vote_result.json new file mode 100644 index 0000000..11281a6 --- /dev/null +++ b/Tests/Runtime/Resources/Fixtures/match.vote_result.json @@ -0,0 +1 @@ +{"type":"match.vote_result","payload":{"vote_id":"01j8x000000000000000000004","winner":"a"}} diff --git a/Tests/Runtime/Resources/Fixtures/match.vote_start.json b/Tests/Runtime/Resources/Fixtures/match.vote_start.json new file mode 100644 index 0000000..869bb72 --- /dev/null +++ b/Tests/Runtime/Resources/Fixtures/match.vote_start.json @@ -0,0 +1 @@ +{"type":"match.vote_start","payload":{"vote_id":"01j8x000000000000000000004","template":"map_pick","options":[{"id":"a","label":"Arena"}]}} diff --git a/Tests/Runtime/Resources/Fixtures/match.vote_tally.json b/Tests/Runtime/Resources/Fixtures/match.vote_tally.json new file mode 100644 index 0000000..a7c5be7 --- /dev/null +++ b/Tests/Runtime/Resources/Fixtures/match.vote_tally.json @@ -0,0 +1 @@ +{"type":"match.vote_tally","payload":{"vote_id":"01j8x000000000000000000004","tally":{"a":1}}} diff --git a/Tests/Runtime/Resources/Fixtures/match.vote_vetoed.json b/Tests/Runtime/Resources/Fixtures/match.vote_vetoed.json new file mode 100644 index 0000000..f7806ff --- /dev/null +++ b/Tests/Runtime/Resources/Fixtures/match.vote_vetoed.json @@ -0,0 +1 @@ +{"type":"match.vote_vetoed","payload":{"vote_id":"01j8x000000000000000000004"}} diff --git a/Tests/Runtime/Resources/Fixtures/matchmaker.queued.json b/Tests/Runtime/Resources/Fixtures/matchmaker.queued.json new file mode 100644 index 0000000..a96541c --- /dev/null +++ b/Tests/Runtime/Resources/Fixtures/matchmaker.queued.json @@ -0,0 +1 @@ +{"type":"matchmaker.queued","cid":"5","payload":{"ticket_id":"01j8x000000000000000000003","status":"pending"}} diff --git a/Tests/Runtime/Resources/Fixtures/matchmaker.removed.json b/Tests/Runtime/Resources/Fixtures/matchmaker.removed.json new file mode 100644 index 0000000..49f9d93 --- /dev/null +++ b/Tests/Runtime/Resources/Fixtures/matchmaker.removed.json @@ -0,0 +1 @@ +{"type":"matchmaker.removed","cid":"6","payload":{"success":true}} diff --git a/Tests/Runtime/Resources/Fixtures/notification.new.json b/Tests/Runtime/Resources/Fixtures/notification.new.json new file mode 100644 index 0000000..61b2231 --- /dev/null +++ b/Tests/Runtime/Resources/Fixtures/notification.new.json @@ -0,0 +1 @@ +{"type":"notification.new","payload":{"id":"01j8x000000000000000000006","kind":"friend_request","from":"01j8x000000000000000000002"}} diff --git a/Tests/Runtime/Resources/Fixtures/presence.updated.json b/Tests/Runtime/Resources/Fixtures/presence.updated.json new file mode 100644 index 0000000..7dc9050 --- /dev/null +++ b/Tests/Runtime/Resources/Fixtures/presence.updated.json @@ -0,0 +1 @@ +{"type":"presence.updated","cid":"10","payload":{"status":"online"}} diff --git a/Tests/Runtime/Resources/Fixtures/session.connected.json b/Tests/Runtime/Resources/Fixtures/session.connected.json new file mode 100644 index 0000000..be8aa9c --- /dev/null +++ b/Tests/Runtime/Resources/Fixtures/session.connected.json @@ -0,0 +1 @@ +{"type":"session.connected","cid":"1","payload":{"player_id":"01j8x000000000000000000000"}} diff --git a/Tests/Runtime/Resources/Fixtures/session.heartbeat.json b/Tests/Runtime/Resources/Fixtures/session.heartbeat.json new file mode 100644 index 0000000..b82222d --- /dev/null +++ b/Tests/Runtime/Resources/Fixtures/session.heartbeat.json @@ -0,0 +1 @@ +{"type":"session.heartbeat","cid":"2","payload":{"ts":1730000000000}} diff --git a/Tests/Runtime/Resources/Fixtures/vote.cast_ok.json b/Tests/Runtime/Resources/Fixtures/vote.cast_ok.json new file mode 100644 index 0000000..60fb68a --- /dev/null +++ b/Tests/Runtime/Resources/Fixtures/vote.cast_ok.json @@ -0,0 +1 @@ +{"type":"vote.cast_ok","cid":"11","payload":{"success":true}} diff --git a/Tests/Runtime/Resources/Fixtures/vote.veto_ok.json b/Tests/Runtime/Resources/Fixtures/vote.veto_ok.json new file mode 100644 index 0000000..4321de6 --- /dev/null +++ b/Tests/Runtime/Resources/Fixtures/vote.veto_ok.json @@ -0,0 +1 @@ +{"type":"vote.veto_ok","cid":"12","payload":{"success":true}} diff --git a/Tests/Runtime/Resources/Fixtures/world.finished.json b/Tests/Runtime/Resources/Fixtures/world.finished.json new file mode 100644 index 0000000..5458593 --- /dev/null +++ b/Tests/Runtime/Resources/Fixtures/world.finished.json @@ -0,0 +1 @@ +{"type":"world.finished","payload":{"world_id":"01j8x000000000000000000007","result":{"winner":"01j8x000000000000000000000"}}} diff --git a/Tests/Runtime/Resources/Fixtures/world.joined.json b/Tests/Runtime/Resources/Fixtures/world.joined.json new file mode 100644 index 0000000..7017bf4 --- /dev/null +++ b/Tests/Runtime/Resources/Fixtures/world.joined.json @@ -0,0 +1 @@ +{"type":"world.joined","cid":"14","payload":{"world_id":"01j8x000000000000000000007","mode":"village"}} diff --git a/Tests/Runtime/Resources/Fixtures/world.left.json b/Tests/Runtime/Resources/Fixtures/world.left.json new file mode 100644 index 0000000..e717961 --- /dev/null +++ b/Tests/Runtime/Resources/Fixtures/world.left.json @@ -0,0 +1 @@ +{"type":"world.left","cid":"15","payload":{"success":true}} diff --git a/Tests/Runtime/Resources/Fixtures/world.list.json b/Tests/Runtime/Resources/Fixtures/world.list.json new file mode 100644 index 0000000..f064e87 --- /dev/null +++ b/Tests/Runtime/Resources/Fixtures/world.list.json @@ -0,0 +1 @@ +{"type":"world.list","cid":"13","payload":{"worlds":[{"id":"01j8x000000000000000000007","mode":"village","capacity":16,"size":3}]}} diff --git a/Tests/Runtime/Resources/Fixtures/world.phase_changed.json b/Tests/Runtime/Resources/Fixtures/world.phase_changed.json new file mode 100644 index 0000000..4e7b738 --- /dev/null +++ b/Tests/Runtime/Resources/Fixtures/world.phase_changed.json @@ -0,0 +1 @@ +{"type":"world.phase_changed","payload":{"phase":"event","started_at":1730000000000}} diff --git a/Tests/Runtime/Resources/Fixtures/world.terrain.json b/Tests/Runtime/Resources/Fixtures/world.terrain.json new file mode 100644 index 0000000..1903a91 --- /dev/null +++ b/Tests/Runtime/Resources/Fixtures/world.terrain.json @@ -0,0 +1 @@ +{"type":"world.terrain","payload":{"coords":[0,0],"data":""}} diff --git a/Tests/Runtime/Resources/Fixtures/world.tick.json b/Tests/Runtime/Resources/Fixtures/world.tick.json new file mode 100644 index 0000000..3778eca --- /dev/null +++ b/Tests/Runtime/Resources/Fixtures/world.tick.json @@ -0,0 +1 @@ +{"type":"world.tick","payload":{"tick":42,"updates":[{"id":"01j8x000000000000000000000","op":"a","x":120,"y":80}]}} diff --git a/Tests/Runtime/SmokeTest.cs b/Tests/Runtime/SmokeTest.cs index 03b09e7..33c0126 100644 --- a/Tests/Runtime/SmokeTest.cs +++ b/Tests/Runtime/SmokeTest.cs @@ -53,8 +53,8 @@ private static async Task RunFlow() // match.matched listeners BEFORE queueing to avoid races. var matchedA = new TaskCompletionSource(); var matchedB = new TaskCompletionSource(); - a.client.Realtime.OnMatchmakerMatched += p => matchedA.TrySetResult(p.match_id); - b.client.Realtime.OnMatchmakerMatched += p => matchedB.TrySetResult(p.match_id); + a.client.Realtime.OnMatchmakerMatched += raw => matchedA.TrySetResult(JsonHelper.ExtractJsonField(raw, "match_id")); + b.client.Realtime.OnMatchmakerMatched += raw => matchedB.TrySetResult(JsonHelper.ExtractJsonField(raw, "match_id")); await a.client.Matchmaker.AddAsync(MatchMode); await b.client.Matchmaker.AddAsync(MatchMode); @@ -70,18 +70,22 @@ private static async Task RunFlow() throw new Exception($"match_id mismatch: {matchedA.Task.Result} vs {matchedB.Task.Result}"); Log($"Both matched, match_id = {matchedA.Task.Result}"); - var stateTcs = new TaskCompletionSource(); - a.client.Realtime.OnMatchState += state => + var stateTcs = new TaskCompletionSource(); + var playerKey = "\"" + a.client.PlayerId + "\""; + a.client.Realtime.OnMatchState += raw => { - if (state.players != null - && state.players.TryGetValue(a.client.PlayerId, out var me) - && me.x >= 1f) + if (!raw.Contains(playerKey)) return; + var idx = raw.IndexOf(playerKey, StringComparison.Ordinal); + var xField = JsonHelper.ExtractJsonField(raw.Substring(idx), "x"); + if (xField != null + && float.TryParse(xField, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var xVal) + && xVal >= 1f) { - stateTcs.TrySetResult(me.x); + stateTcs.TrySetResult(xField); } }; - a.client.Realtime.SendMatchInput(new MatchInput { move_x = 1, move_y = 0 }); + await a.client.Realtime.SendMatchInputAsync("{\"move_x\":1,\"move_y\":0}"); var stateTimeout = Task.Delay(TimeSpan.FromSeconds(StateTimeoutSec)); var stateWinner = await Task.WhenAny(stateTcs.Task, stateTimeout); @@ -89,8 +93,8 @@ private static async Task RunFlow() throw new Exception("timeout waiting for match.state with input applied"); Log($"match.state confirmed: x = {stateTcs.Task.Result}"); - a.client.Realtime.Disconnect(); - b.client.Realtime.Disconnect(); + await a.client.Realtime.DisconnectAsync(); + await b.client.Realtime.DisconnectAsync(); Log("PASS"); }