diff --git a/Runtime/WebSocket/AsobiRealtime.cs b/Runtime/WebSocket/AsobiRealtime.cs index ceb1286..5c57aae 100644 --- a/Runtime/WebSocket/AsobiRealtime.cs +++ b/Runtime/WebSocket/AsobiRealtime.cs @@ -145,6 +145,88 @@ public Task MatchListAsync(string mode = null, bool? hasCapacity = null, return SendAsync("match.list", payload); } + /// + /// Get into a live match of a mode, spawning one if there is none. + /// The match twin of . + /// + /// + /// The match mode. Match parameters are not client-chosen: size, min + /// players and the rest come from the server-side mode config. + /// + /// + /// The raw match.joined envelope, the same frame + /// replies with, so the reply routes + /// exactly as that one does. + /// + /// + /// Reach for this when you want a player in a match now. + /// followed by + /// can only join a match that already + /// exists, and no client call spawns one: the matchmaker spawns only + /// once it has grouped enough co-queued tickets, and it never drops a + /// player into a running match. So before this frame an empty listing + /// left the caller with nothing but the queue. This finds a live match + /// or spawns one, resolved server-side and serialized, so simultaneous + /// callers converge on one match. + /// + /// A mode opts into it with quick_play, which defaults to false + /// for match modes; a mode that has not opted in is refused with + /// quick_play_disabled. That is a separate axis from + /// listed, which only decides browser visibility. + /// + /// Refusals include not_found (the mode is unknown or not + /// configured, so a misspelt name lands here), quick_play_disabled, + /// wrong_mode_type (a world mode), match_capacity_reached + /// (node-wide cap) and join_rate_limited (the same bucket as + /// and ). More + /// can be added, so treat an unrecognised reason as a refusal as well. + /// + /// A refusal faults the task with , whose + /// StatusCode is always -1 for a WebSocket error frame and so + /// carries nothing. The reason is payload.reason in the raw + /// envelope, which is what Message holds. Read it with the SDK's + /// own scanner: + /// + /// catch (AsobiException ex) + /// { + /// var payload = JsonScan.ExtractField(ex.Message, "payload"); + /// var reason = JsonScan.Unquote(JsonScan.ExtractField(payload, "reason")); + /// if (reason == "quick_play_disabled") ShowModeClosed(); + /// } + /// + /// Branch on payload.reason rather than payload.error.code: + /// several reasons here share the generic ws.request_failed code, + /// though not all do - join_refused, match_full and + /// match_locked carry their own mapped codes. The reason string + /// is the one field that tells every refusal apart. + /// + /// One refusal has a second level worth reading. The lobby can refuse + /// before the join (not_found, quick_play_disabled, + /// wrong_mode_type, match_capacity_reached), and the join + /// itself can refuse after a match is found (join_refused, + /// match_full, match_locked). When a game's own + /// join script refuses, reason is the fixed literal + /// join_refused and the script's own wording is one level + /// deeper, at payload.error.details.refused_reason - a script + /// cannot mint an error code, so its text travels as a detail. That + /// string is the one to show a player: + /// + /// if (reason == "join_refused") + /// { + /// var error = JsonScan.ExtractField(payload, "error"); + /// var details = JsonScan.ExtractField(error, "details"); + /// ShowRefused(JsonScan.Unquote(JsonScan.ExtractField(details, "refused_reason"))); + /// } + /// + /// + /// Requires asobi core v0.86.0 or later. + /// + public Task MatchFindOrCreateAsync(string mode) + { + var payload = JsonUtility.ToJson(new WsMatchmakerPayload { mode = mode }); + return SendAsync("match.find_or_create", payload); + } + public Task JoinMatchAsync(string matchId) { var payload = JsonUtility.ToJson(new WsMatchJoinPayload { match_id = matchId }); diff --git a/Tests/AsobiCore.NET/ErrorParserTests.cs b/Tests/AsobiCore.NET/ErrorParserTests.cs index df659fb..d67c940 100644 --- a/Tests/AsobiCore.NET/ErrorParserTests.cs +++ b/Tests/AsobiCore.NET/ErrorParserTests.cs @@ -57,5 +57,25 @@ public void UnescapesAQuotedMessage() Assert.That(err.error, Is.EqualTo("He said \"no\".")); } + + // A WebSocket refusal faults the task with AsobiException carrying the + // raw envelope, so the reason is only readable off payload.reason. The + // nested error object maps most refusals onto one code, which is why + // MatchFindOrCreateAsync's doc points callers at the reason instead. + [Test] + public void AWsRefusalReasonIsReadFromThePayloadNotFromTheErrorCode() + { + const string raw = + "{\"type\":\"error\",\"payload\":{\"reason\":\"quick_play_disabled\"," + + "\"error\":{\"code\":\"ws.request_failed\"," + + "\"message\":\"The request failed. See `details.reason`.\"," + + "\"details\":{\"reason\":\"quick_play_disabled\"}}}}"; + + var payload = JsonScan.ExtractField(raw, "payload"); + var reason = JsonScan.Unquote(JsonScan.ExtractField(payload, "reason")); + + Assert.That(reason, Is.EqualTo("quick_play_disabled")); + Assert.That(AsobiErrorParser.Parse(raw).code, Is.EqualTo("ws.request_failed")); + } } }