Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 06c3973ce7 | |||
| a9a2b3db35 |
@@ -207,6 +207,13 @@
|
||||
"format": "int32"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "excludeFull",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "cursor",
|
||||
"in": "query",
|
||||
@@ -226,8 +233,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"501": {
|
||||
"description": "Not Implemented",
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"503": {
|
||||
"description": "Service Unavailable",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
@@ -530,6 +547,40 @@
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "contractVersion",
|
||||
"in": "query",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "gameId",
|
||||
"in": "query",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "environmentId",
|
||||
"in": "query",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "protocolVersion",
|
||||
"in": "query",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "uint32"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
@@ -543,8 +594,28 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"501": {
|
||||
"description": "Not Implemented",
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"503": {
|
||||
"description": "Service Unavailable",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
# ADR 0006: bounded compatible session browser
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-07-16
|
||||
- Tracking: #8
|
||||
|
||||
## Decision
|
||||
|
||||
The public list endpoint requires game, environment, and exact gameplay protocol.
|
||||
Region is optional, page size is 1–100, and callers may exclude sessions whose
|
||||
advisory current-player count has reached the advertised maximum. Lists contain
|
||||
public sessions only and only while both lease and authenticated host presence are
|
||||
fresh. Unlisted sessions never appear in a list; they may be retrieved directly by
|
||||
their 128-bit unguessable listing ID only when the caller also supplies the exact
|
||||
game, environment, and protocol scope.
|
||||
|
||||
Results use ascending opaque listing ID as a deterministic keyset. A cursor carries
|
||||
the last ID plus every compatibility/filter field, a five-minute expiry, and an
|
||||
HMAC-SHA256 signature under a per-process key. Tampering, expiry, or reuse with a
|
||||
different tenant/protocol/region/full filter returns `InvalidRequest`. Restart
|
||||
rotates the key, matching the loss of ephemeral listings.
|
||||
|
||||
Pagination is a bounded live view, not a database snapshot. A record that remains
|
||||
eligible and whose ID is greater than the cursor is returned exactly once. Records
|
||||
removed or made stale disappear immediately. A record created after a page whose ID
|
||||
sorts before that page's cursor is outside that traversal; callers refresh from the
|
||||
first page to discover new sessions. This avoids skips or duplicates among stable
|
||||
eligible records without retaining per-browser snapshot state.
|
||||
|
||||
The store reads at most page size plus one record. The service serializes against
|
||||
the 256 KiB response ceiling and shortens a page before returning it when metadata
|
||||
makes the requested count too large. A continuation cursor is emitted whenever an
|
||||
extra or byte-trimmed record remains. All cursor, page, metadata, property, scalar,
|
||||
and collection sizes are bounded before untrusted allocation can grow without a
|
||||
ceiling.
|
||||
|
||||
Browser DTOs are fresh copies containing only opaque listing ID, exact compatibility,
|
||||
region, visibility/trust presentation, advisory capacity, build/display labels, and
|
||||
policy-validated string metadata. They contain no observed endpoint, lease,
|
||||
capability, ticket, credential fingerprint, derivation salt, principal subject, or
|
||||
store key. Metadata is display text: JSON encoding escapes markup, but game UI must
|
||||
still render values as text and must never execute markup, interpret endpoints, or
|
||||
use metadata for authorization.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Cross-game, cross-environment, incompatible, stale, revoked, expired, unlisted,
|
||||
and optionally full sessions are removed before response construction.
|
||||
- Direct unlisted lookup is suitable for an out-of-band invite carrying the opaque
|
||||
ID; human join codes remain future work and require their own bounded abuse model.
|
||||
- Host capacity remains advisory. The host makes the final admission decision.
|
||||
@@ -0,0 +1,53 @@
|
||||
# ADR 0007: caller-owned .NET publisher and browser SDK
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-07-16
|
||||
- Tracking: #9
|
||||
|
||||
## Decision
|
||||
|
||||
The .NET client package exposes separate publisher and browser interfaces plus
|
||||
concrete clients over a caller-supplied `HttpClient`. The caller owns that client,
|
||||
its handler, base address, connection pool, proxy, and lifetime. SDK operations
|
||||
dispose every request, response, and response body they create, but never dispose
|
||||
the supplied client. The package targets `netstandard2.1`, depends only on the
|
||||
wire-contract package and LiteNetLib, and contains no Godot types, global client,
|
||||
service URL, publisher secret, or embedded game credential.
|
||||
|
||||
Every operation returns `RendezvousClientResult<T>` with a stable error code,
|
||||
message, and optional retry guidance. Cancellation remains exceptional through
|
||||
the caller's `CancellationToken`; transport failures become `ServiceUnavailable`.
|
||||
Response bodies are streamed under the contract's 256 KiB browser ceiling before
|
||||
deserialization. Invalid or oversized success bodies become `InternalError` and
|
||||
never escape as partially trusted contract objects.
|
||||
|
||||
The SDK retries only operations whose duplicate execution is safe: scoped reads,
|
||||
idempotency-keyed registration, lease renewal with the same lease token, complete
|
||||
resource update, and lease-token deregistration. It honors bounded server retry
|
||||
guidance and otherwise uses capped exponential backoff with jitter. Each retry
|
||||
creates a fresh HTTP request while preserving the caller's registration
|
||||
idempotency key. Configuration is copied on construction so later option mutation
|
||||
cannot change an in-flight client's behavior.
|
||||
|
||||
`PublishedSession` holds the server-issued lease and presence capabilities needed
|
||||
by the host. Its string representation always redacts them. Update requests are
|
||||
copied before the lease token is attached, so the SDK never mutates caller-owned
|
||||
DTOs. The browser exposes one-page calls and bounded cursor traversal; cursor
|
||||
values remain opaque and caller requests remain unchanged.
|
||||
|
||||
Lease maintenance is explicit. Creating a `SessionLeaseMaintainer` starts no task;
|
||||
the game chooses when to call `RunAsync`, owns cancellation, and awaits
|
||||
`DisposeAsync`. The loop uses the latest server-provided renewal interval and
|
||||
returns a distinct cancelled, disposed, lost-lease, or failed result. Terminal
|
||||
authorization, expiry, and missing-lease responses also raise `LeaseLost` so the
|
||||
host can stop advertising or re-register deliberately.
|
||||
|
||||
## Consequences
|
||||
|
||||
- SpaceGame and Unscouted can inject the publisher/browser interfaces in tests
|
||||
without an engine runtime or real network.
|
||||
- Games must configure an absolute `HttpClient.BaseAddress` (or equivalent
|
||||
handler routing), obtain publisher credentials from their deployment boundary,
|
||||
and explicitly run and dispose lease maintenance.
|
||||
- The versioned client public-API snapshot and live-server integration tests fail
|
||||
together when SDK and HTTP contracts drift.
|
||||
@@ -8,6 +8,8 @@ decision requires a superseding ADR and corresponding contract/test updates.
|
||||
- [ADR 0003: state, privacy, availability, and safety budgets](0003-state-privacy-availability-and-budgets.md)
|
||||
- [ADR 0004: atomic ephemeral state and single-active availability](0004-atomic-ephemeral-state.md)
|
||||
- [ADR 0005: authenticated session lease and presence lifecycle](0005-session-lease-lifecycle.md)
|
||||
- [ADR 0006: bounded compatible session browser](0006-compatible-session-browser.md)
|
||||
- [ADR 0007: caller-owned .NET publisher and browser SDK](0007-caller-owned-dotnet-client-sdk.md)
|
||||
- [Threat model](../security/threat-model.md)
|
||||
- [Security promise and test matrix](../security/control-matrix.md)
|
||||
- [Versioned HTTP and UDP contracts](../contracts/README.md)
|
||||
|
||||
@@ -5,10 +5,12 @@
|
||||
<RootNamespace>FinalFactory.Rendezvous.Client</RootNamespace>
|
||||
<IsPackable>true</IsPackable>
|
||||
<PackageId>FinalFactory.Rendezvous.Client</PackageId>
|
||||
<PackageReadmeFile>README.md</PackageReadmeFile>
|
||||
<Description>Godot-independent client SDK for Final Factory Rendezvous.</Description>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../FinalFactory.Rendezvous.Contracts/FinalFactory.Rendezvous.Contracts.csproj" />
|
||||
<PackageReference Include="LiteNetLib" />
|
||||
<None Update="README.md" Pack="true" PackagePath="\" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
# FinalFactory.Rendezvous.Client
|
||||
|
||||
Godot-independent .NET publisher and session-browser SDK for Rendezvous v1.
|
||||
The package targets `netstandard2.1` and uses a caller-owned `HttpClient`.
|
||||
|
||||
```csharp
|
||||
using FinalFactory.Rendezvous.Client;
|
||||
using FinalFactory.Rendezvous.Contracts;
|
||||
|
||||
using HttpClient http = new()
|
||||
{
|
||||
BaseAddress = new Uri("https://rendezvous.example/"),
|
||||
};
|
||||
|
||||
string publisherCredential = Environment.GetEnvironmentVariable(
|
||||
"RENDEZVOUS_PUBLISHER_CREDENTIAL")
|
||||
?? throw new InvalidOperationException("Publisher credential is not configured.");
|
||||
CancellationToken cancellationToken = default;
|
||||
RendezvousPublisherClient publisher = new(http);
|
||||
RendezvousClientResult<PublishedSession> registered = await publisher.RegisterAsync(
|
||||
new RegisterSessionRequest
|
||||
{
|
||||
IdempotencyKey = Guid.NewGuid().ToString("N"),
|
||||
GameId = new("space-game"),
|
||||
EnvironmentId = new("production"),
|
||||
RegionId = new("eu-central"),
|
||||
ProtocolVersion = 7,
|
||||
BuildVersion = "1.0.0",
|
||||
DisplayName = "My server",
|
||||
Visibility = ListingVisibility.Public,
|
||||
Capacity = new() { CurrentPlayers = 1, MaximumPlayers = 8 },
|
||||
},
|
||||
publisherCredential,
|
||||
cancellationToken);
|
||||
if (!registered.IsSuccess || registered.Value is null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Registration failed: {registered.Error} ({registered.Message})");
|
||||
}
|
||||
```
|
||||
|
||||
Load `publisherCredential` from the game's deployment secret boundary; never
|
||||
embed it in a client build or source control. A successful registration returns a
|
||||
`PublishedSession` containing the lease and host-presence capabilities.
|
||||
|
||||
Lease renewal is explicit and caller-controlled:
|
||||
|
||||
```csharp
|
||||
PublishedSession session = registered.Value;
|
||||
await using SessionLeaseMaintainer maintainer = publisher.CreateLeaseMaintainer(
|
||||
session,
|
||||
publisherCredential);
|
||||
LeaseMaintenanceResult stopped = await maintainer.RunAsync(cancellationToken);
|
||||
```
|
||||
|
||||
Creating the maintainer does not start background work. Await its run and dispose
|
||||
it when hosting stops. Use `IRendezvousPublisherClient` and
|
||||
`IRendezvousSessionBrowserClient` as injection seams in game tests. The SDK disposes
|
||||
the requests and responses it creates but never disposes the supplied `HttpClient`.
|
||||
|
||||
See the repository's ADR 0007 for retry, paging, ownership, and failure semantics.
|
||||
@@ -0,0 +1,141 @@
|
||||
using FinalFactory.Rendezvous.Contracts;
|
||||
|
||||
namespace FinalFactory.Rendezvous.Client;
|
||||
|
||||
public sealed class RendezvousClientResult<T>
|
||||
{
|
||||
internal RendezvousClientResult(
|
||||
RendezvousErrorCode error,
|
||||
T? value,
|
||||
string message,
|
||||
int? retryAfterSeconds)
|
||||
{
|
||||
Error = error;
|
||||
Value = value;
|
||||
Message = message;
|
||||
RetryAfterSeconds = retryAfterSeconds;
|
||||
}
|
||||
|
||||
public bool IsSuccess => Error == RendezvousErrorCode.None;
|
||||
public RendezvousErrorCode Error { get; }
|
||||
public T? Value { get; }
|
||||
public string Message { get; }
|
||||
public int? RetryAfterSeconds { get; }
|
||||
|
||||
}
|
||||
|
||||
public static class RendezvousClientResult
|
||||
{
|
||||
public static RendezvousClientResult<T> Success<T>(T value) =>
|
||||
value is null
|
||||
? throw new ArgumentNullException(nameof(value))
|
||||
: new(RendezvousErrorCode.None, value, string.Empty, null);
|
||||
|
||||
public static RendezvousClientResult<T> Failure<T>(
|
||||
RendezvousErrorCode error,
|
||||
string message,
|
||||
int? retryAfterSeconds = null) =>
|
||||
error == RendezvousErrorCode.None
|
||||
? throw new ArgumentException("A failure requires a non-success error.", nameof(error))
|
||||
: new(error, default, message ?? string.Empty, retryAfterSeconds);
|
||||
}
|
||||
|
||||
public sealed class PublishedSession
|
||||
{
|
||||
internal PublishedSession(RegisterSessionResponse response)
|
||||
{
|
||||
ListingId = response.ListingId;
|
||||
LeaseId = response.LeaseId;
|
||||
LeaseToken = response.LeaseToken;
|
||||
HostPresenceHandle = response.HostPresenceHandle;
|
||||
HostPresenceCapability = response.HostPresenceCapability;
|
||||
ExpiresAt = response.ExpiresAt;
|
||||
LeaseRenewAfterSeconds = response.LeaseRenewAfterSeconds;
|
||||
HostPresenceRefreshAfterSeconds = response.HostPresenceRefreshAfterSeconds;
|
||||
}
|
||||
|
||||
public SessionListingId ListingId { get; }
|
||||
public LeaseId LeaseId { get; }
|
||||
public string LeaseToken { get; }
|
||||
public MediationHandle HostPresenceHandle { get; }
|
||||
public string HostPresenceCapability { get; }
|
||||
public DateTimeOffset ExpiresAt { get; internal set; }
|
||||
public int LeaseRenewAfterSeconds { get; internal set; }
|
||||
public int HostPresenceRefreshAfterSeconds { get; }
|
||||
|
||||
public override string ToString() => $"[PublishedSession {ListingId}; credentials redacted]";
|
||||
}
|
||||
|
||||
public interface IRendezvousPublisherClient
|
||||
{
|
||||
Task<RendezvousClientResult<PublishedSession>> RegisterAsync(
|
||||
RegisterSessionRequest request,
|
||||
string publisherCredential,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<RendezvousClientResult<RenewLeaseResponse>> RenewAsync(
|
||||
PublishedSession session,
|
||||
string publisherCredential,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<RendezvousClientResult<bool>> UpdateAsync(
|
||||
PublishedSession session,
|
||||
UpdateSessionRequest request,
|
||||
string publisherCredential,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<RendezvousClientResult<bool>> DeregisterAsync(
|
||||
PublishedSession session,
|
||||
string publisherCredential,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public interface IRendezvousSessionBrowserClient
|
||||
{
|
||||
Task<RendezvousClientResult<BrowseSessionsResponse>> BrowseAsync(
|
||||
BrowseSessionsRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<RendezvousClientResult<IReadOnlyList<SessionListing>>> BrowseAllAsync(
|
||||
BrowseSessionsRequest request,
|
||||
int maximumPages = 100,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<RendezvousClientResult<GetSessionResponse>> GetAsync(
|
||||
SessionListingId listingId,
|
||||
GameId gameId,
|
||||
EnvironmentId environmentId,
|
||||
uint protocolVersion,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public interface IRendezvousDelay
|
||||
{
|
||||
Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed class RendezvousClientOptions
|
||||
{
|
||||
public int MaximumSafeRetries { get; set; } = 2;
|
||||
public TimeSpan InitialRetryDelay { get; set; } = TimeSpan.FromMilliseconds(200);
|
||||
public TimeSpan MaximumRetryDelay { get; set; } = TimeSpan.FromSeconds(2);
|
||||
public double JitterRatio { get; set; } = 0.2;
|
||||
|
||||
internal void Validate()
|
||||
{
|
||||
if (MaximumSafeRetries is < 0 or > 5
|
||||
|| InitialRetryDelay < TimeSpan.Zero
|
||||
|| MaximumRetryDelay < InitialRetryDelay
|
||||
|| MaximumRetryDelay > TimeSpan.FromSeconds(30)
|
||||
|| JitterRatio is < 0 or > 1)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(RendezvousClientOptions));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class SystemRendezvousDelay : IRendezvousDelay
|
||||
{
|
||||
public Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken) =>
|
||||
Task.Delay(delay, cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using FinalFactory.Rendezvous.Contracts;
|
||||
|
||||
namespace FinalFactory.Rendezvous.Client;
|
||||
|
||||
internal sealed class RendezvousHttpTransport
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly RendezvousClientOptions _options;
|
||||
private readonly IRendezvousDelay _delay;
|
||||
|
||||
internal RendezvousHttpTransport(
|
||||
HttpClient httpClient,
|
||||
RendezvousClientOptions? options,
|
||||
IRendezvousDelay? delay)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
RendezvousClientOptions suppliedOptions = options ?? new RendezvousClientOptions();
|
||||
suppliedOptions.Validate();
|
||||
_options = new RendezvousClientOptions
|
||||
{
|
||||
MaximumSafeRetries = suppliedOptions.MaximumSafeRetries,
|
||||
InitialRetryDelay = suppliedOptions.InitialRetryDelay,
|
||||
MaximumRetryDelay = suppliedOptions.MaximumRetryDelay,
|
||||
JitterRatio = suppliedOptions.JitterRatio,
|
||||
};
|
||||
_delay = delay ?? new SystemRendezvousDelay();
|
||||
}
|
||||
|
||||
internal async Task<RendezvousClientResult<T>> SendSafeAsync<T>(
|
||||
Func<HttpRequestMessage> requestFactory,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
for (int attempt = 0; ; attempt++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
try
|
||||
{
|
||||
using HttpRequestMessage request = requestFactory();
|
||||
using HttpResponseMessage response = await _httpClient
|
||||
.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
if (typeof(T) == typeof(bool) && response.StatusCode == HttpStatusCode.NoContent)
|
||||
{
|
||||
return RendezvousClientResult.Success((T)(object)true);
|
||||
}
|
||||
|
||||
byte[] payload;
|
||||
try
|
||||
{
|
||||
payload = await ReadBoundedAsync(response.Content, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (InvalidDataException)
|
||||
{
|
||||
return RendezvousClientResult.Failure<T>(
|
||||
RendezvousErrorCode.InternalError,
|
||||
"The service returned an oversized success response.");
|
||||
}
|
||||
|
||||
T? value;
|
||||
try
|
||||
{
|
||||
value = JsonSerializer.Deserialize<T>(payload, ContractJson.Options);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
value = default;
|
||||
}
|
||||
|
||||
return value is null
|
||||
? RendezvousClientResult.Failure<T>(
|
||||
RendezvousErrorCode.InternalError,
|
||||
"The service returned an invalid success response.")
|
||||
: RendezvousClientResult.Success(value);
|
||||
}
|
||||
|
||||
ApiError error = await ReadErrorAsync(response, cancellationToken).ConfigureAwait(false);
|
||||
int? retryAfter = error.RetryAfterSeconds ?? GetRetryAfterSeconds(response.Headers.RetryAfter);
|
||||
if (attempt < _options.MaximumSafeRetries && IsTransient(error.Code))
|
||||
{
|
||||
await _delay.DelayAsync(
|
||||
GetRetryDelay(attempt, retryAfter),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
continue;
|
||||
}
|
||||
|
||||
return RendezvousClientResult.Failure<T>(error.Code, error.Message, retryAfter);
|
||||
}
|
||||
catch (Exception exception) when (
|
||||
IsTransientTransportFailure(exception, cancellationToken)
|
||||
&& attempt < _options.MaximumSafeRetries)
|
||||
{
|
||||
await _delay.DelayAsync(GetRetryDelay(attempt, null), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception exception) when (IsTransientTransportFailure(exception, cancellationToken))
|
||||
{
|
||||
return RendezvousClientResult.Failure<T>(
|
||||
RendezvousErrorCode.ServiceUnavailable,
|
||||
"The Rendezvous service did not return a valid response.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal static HttpRequestMessage JsonRequest<T>(
|
||||
HttpMethod method,
|
||||
string uri,
|
||||
T body,
|
||||
string? publisherCredential = null)
|
||||
{
|
||||
HttpRequestMessage request = new(method, uri)
|
||||
{
|
||||
Content = new StringContent(
|
||||
JsonSerializer.Serialize(body, ContractJson.Options),
|
||||
Encoding.UTF8,
|
||||
"application/json"),
|
||||
};
|
||||
if (publisherCredential is not null)
|
||||
{
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue(
|
||||
"Bearer",
|
||||
RequireCredential(publisherCredential));
|
||||
}
|
||||
|
||||
return request;
|
||||
}
|
||||
|
||||
internal static string RequireCredential(string credential) =>
|
||||
!string.IsNullOrWhiteSpace(credential)
|
||||
? credential
|
||||
: throw new ArgumentException("A publisher credential is required.", nameof(credential));
|
||||
|
||||
private static async Task<ApiError> ReadErrorAsync(
|
||||
HttpResponseMessage response,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
byte[] payload = await ReadBoundedAsync(response.Content, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
ApiError? error = JsonSerializer.Deserialize<ApiError>(payload, ContractJson.Options);
|
||||
return error is not null && error.Code != RendezvousErrorCode.None
|
||||
? error
|
||||
: FallbackError(response.StatusCode);
|
||||
}
|
||||
catch (Exception exception) when (exception is JsonException or InvalidDataException)
|
||||
{
|
||||
return FallbackError(response.StatusCode);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<byte[]> ReadBoundedAsync(
|
||||
HttpContent content,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using Stream source = await content.ReadAsStreamAsync().ConfigureAwait(false);
|
||||
using MemoryStream destination = new();
|
||||
byte[] buffer = new byte[8192];
|
||||
while (true)
|
||||
{
|
||||
int read = await source.ReadAsync(buffer.AsMemory(), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (read == 0)
|
||||
{
|
||||
return destination.ToArray();
|
||||
}
|
||||
|
||||
if (destination.Length + read > ContractLimits.BrowserResponseMaxBytes)
|
||||
{
|
||||
throw new InvalidDataException("The service response exceeded the SDK limit.");
|
||||
}
|
||||
|
||||
await destination.WriteAsync(buffer.AsMemory(0, read), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private TimeSpan GetRetryDelay(int attempt, int? retryAfterSeconds)
|
||||
{
|
||||
TimeSpan basis = retryAfterSeconds.HasValue
|
||||
? TimeSpan.FromSeconds(Math.Max(0, retryAfterSeconds.Value))
|
||||
: TimeSpan.FromMilliseconds(
|
||||
_options.InitialRetryDelay.TotalMilliseconds * Math.Pow(2, attempt));
|
||||
double bounded = Math.Min(basis.TotalMilliseconds, _options.MaximumRetryDelay.TotalMilliseconds);
|
||||
if (_options.JitterRatio == 0 || bounded == 0)
|
||||
{
|
||||
return TimeSpan.FromMilliseconds(bounded);
|
||||
}
|
||||
|
||||
byte[] random = new byte[1];
|
||||
RandomNumberGenerator.Fill(random);
|
||||
double unit = random[0] / 255d;
|
||||
double multiplier = 1 - _options.JitterRatio + (2 * _options.JitterRatio * unit);
|
||||
return TimeSpan.FromMilliseconds(Math.Min(
|
||||
bounded * multiplier,
|
||||
_options.MaximumRetryDelay.TotalMilliseconds));
|
||||
}
|
||||
|
||||
private static bool IsTransient(RendezvousErrorCode code) => code is
|
||||
RendezvousErrorCode.RateLimited
|
||||
or RendezvousErrorCode.CapacityExceeded
|
||||
or RendezvousErrorCode.ServiceUnavailable;
|
||||
|
||||
private static bool IsTransientTransportFailure(
|
||||
Exception exception,
|
||||
CancellationToken callerCancellation) =>
|
||||
exception is HttpRequestException
|
||||
or IOException
|
||||
|| exception is OperationCanceledException && !callerCancellation.IsCancellationRequested;
|
||||
|
||||
private static int? GetRetryAfterSeconds(RetryConditionHeaderValue? retryAfter) =>
|
||||
retryAfter?.Delta is TimeSpan delta
|
||||
? Math.Max(0, (int)Math.Ceiling(delta.TotalSeconds))
|
||||
: null;
|
||||
|
||||
private static ApiError FallbackError(HttpStatusCode statusCode) => new()
|
||||
{
|
||||
Code = statusCode switch
|
||||
{
|
||||
HttpStatusCode.BadRequest => RendezvousErrorCode.InvalidRequest,
|
||||
HttpStatusCode.Unauthorized => RendezvousErrorCode.AuthenticationRequired,
|
||||
HttpStatusCode.Forbidden => RendezvousErrorCode.Forbidden,
|
||||
HttpStatusCode.NotFound => RendezvousErrorCode.NotFound,
|
||||
HttpStatusCode.Conflict => RendezvousErrorCode.Conflict,
|
||||
HttpStatusCode.Gone => RendezvousErrorCode.Expired,
|
||||
HttpStatusCode.TooManyRequests => RendezvousErrorCode.RateLimited,
|
||||
HttpStatusCode.RequestTimeout => RendezvousErrorCode.ServiceUnavailable,
|
||||
HttpStatusCode.BadGateway => RendezvousErrorCode.ServiceUnavailable,
|
||||
HttpStatusCode.ServiceUnavailable => RendezvousErrorCode.ServiceUnavailable,
|
||||
HttpStatusCode.GatewayTimeout => RendezvousErrorCode.ServiceUnavailable,
|
||||
_ => RendezvousErrorCode.InternalError,
|
||||
},
|
||||
Message = "The service returned an error without a valid Rendezvous envelope.",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
using FinalFactory.Rendezvous.Contracts;
|
||||
|
||||
namespace FinalFactory.Rendezvous.Client;
|
||||
|
||||
public sealed class RendezvousPublisherClient : IRendezvousPublisherClient
|
||||
{
|
||||
private readonly RendezvousHttpTransport _transport;
|
||||
private readonly IRendezvousDelay _delay;
|
||||
|
||||
public RendezvousPublisherClient(
|
||||
HttpClient httpClient,
|
||||
RendezvousClientOptions? options = null,
|
||||
IRendezvousDelay? delay = null)
|
||||
{
|
||||
_delay = delay ?? new SystemRendezvousDelay();
|
||||
_transport = new(httpClient, options, _delay);
|
||||
}
|
||||
|
||||
public async Task<RendezvousClientResult<PublishedSession>> RegisterAsync(
|
||||
RegisterSessionRequest request,
|
||||
string publisherCredential,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (request is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(request));
|
||||
}
|
||||
|
||||
RegisterSessionRequest body = CopyRegistration(request);
|
||||
RendezvousClientResult<RegisterSessionResponse> result = await _transport.SendSafeAsync<RegisterSessionResponse>(
|
||||
() => RendezvousHttpTransport.JsonRequest(HttpMethod.Post, "v1/sessions", body, publisherCredential),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
return result.IsSuccess && result.Value is not null
|
||||
? RendezvousClientResult.Success(new PublishedSession(result.Value))
|
||||
: RendezvousClientResult.Failure<PublishedSession>(
|
||||
result.Error,
|
||||
result.Message,
|
||||
result.RetryAfterSeconds);
|
||||
}
|
||||
|
||||
public async Task<RendezvousClientResult<RenewLeaseResponse>> RenewAsync(
|
||||
PublishedSession session,
|
||||
string publisherCredential,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (session is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(session));
|
||||
}
|
||||
|
||||
RendezvousClientResult<RenewLeaseResponse> result = await _transport.SendSafeAsync<RenewLeaseResponse>(
|
||||
() => RendezvousHttpTransport.JsonRequest(
|
||||
HttpMethod.Post,
|
||||
$"v1/sessions/{session.ListingId}/renew",
|
||||
new RenewLeaseRequest { LeaseToken = session.LeaseToken },
|
||||
publisherCredential),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
if (result.IsSuccess && result.Value is not null)
|
||||
{
|
||||
session.ExpiresAt = result.Value.ExpiresAt;
|
||||
session.LeaseRenewAfterSeconds = result.Value.RenewAfterSeconds;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public Task<RendezvousClientResult<bool>> UpdateAsync(
|
||||
PublishedSession session,
|
||||
UpdateSessionRequest request,
|
||||
string publisherCredential,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (session is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(session));
|
||||
}
|
||||
|
||||
if (request is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(request));
|
||||
}
|
||||
|
||||
UpdateSessionRequest body = new()
|
||||
{
|
||||
ContractVersion = request.ContractVersion,
|
||||
LeaseToken = session.LeaseToken,
|
||||
BuildVersion = request.BuildVersion,
|
||||
DisplayName = request.DisplayName,
|
||||
Capacity = CopyCapacity(request.Capacity),
|
||||
Metadata = CopyMetadata(request.Metadata),
|
||||
};
|
||||
return _transport.SendSafeAsync<bool>(
|
||||
() => RendezvousHttpTransport.JsonRequest(
|
||||
HttpMethod.Put,
|
||||
$"v1/sessions/{session.ListingId}",
|
||||
body,
|
||||
publisherCredential),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public Task<RendezvousClientResult<bool>> DeregisterAsync(
|
||||
PublishedSession session,
|
||||
string publisherCredential,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (session is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(session));
|
||||
}
|
||||
|
||||
return _transport.SendSafeAsync<bool>(
|
||||
() => RendezvousHttpTransport.JsonRequest(
|
||||
HttpMethod.Delete,
|
||||
$"v1/sessions/{session.ListingId}",
|
||||
new DeleteSessionRequest { LeaseToken = session.LeaseToken },
|
||||
publisherCredential),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public SessionLeaseMaintainer CreateLeaseMaintainer(
|
||||
PublishedSession session,
|
||||
string publisherCredential) => new(
|
||||
this,
|
||||
session ?? throw new ArgumentNullException(nameof(session)),
|
||||
RendezvousHttpTransport.RequireCredential(publisherCredential),
|
||||
_delay);
|
||||
|
||||
private static RegisterSessionRequest CopyRegistration(RegisterSessionRequest request) => new()
|
||||
{
|
||||
ContractVersion = request.ContractVersion,
|
||||
IdempotencyKey = request.IdempotencyKey,
|
||||
GameId = request.GameId,
|
||||
EnvironmentId = request.EnvironmentId,
|
||||
RegionId = request.RegionId,
|
||||
ProtocolVersion = request.ProtocolVersion,
|
||||
BuildVersion = request.BuildVersion,
|
||||
DisplayName = request.DisplayName,
|
||||
Visibility = request.Visibility,
|
||||
Capacity = CopyCapacity(request.Capacity),
|
||||
Metadata = CopyMetadata(request.Metadata),
|
||||
};
|
||||
|
||||
private static SessionCapacity CopyCapacity(SessionCapacity capacity) => new()
|
||||
{
|
||||
CurrentPlayers = capacity.CurrentPlayers,
|
||||
MaximumPlayers = capacity.MaximumPlayers,
|
||||
};
|
||||
|
||||
private static Dictionary<string, string> CopyMetadata(Dictionary<string, string> metadata) =>
|
||||
new(metadata, StringComparer.Ordinal);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
using FinalFactory.Rendezvous.Contracts;
|
||||
|
||||
namespace FinalFactory.Rendezvous.Client;
|
||||
|
||||
public sealed class RendezvousSessionBrowserClient : IRendezvousSessionBrowserClient
|
||||
{
|
||||
private readonly RendezvousHttpTransport _transport;
|
||||
|
||||
public RendezvousSessionBrowserClient(
|
||||
HttpClient httpClient,
|
||||
RendezvousClientOptions? options = null,
|
||||
IRendezvousDelay? delay = null)
|
||||
{
|
||||
_transport = new(httpClient, options, delay);
|
||||
}
|
||||
|
||||
public Task<RendezvousClientResult<BrowseSessionsResponse>> BrowseAsync(
|
||||
BrowseSessionsRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (request is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(request));
|
||||
}
|
||||
|
||||
string query = $"v1/sessions?contractVersion={request.ContractVersion}"
|
||||
+ $"&gameId={Escape(request.GameId.Value)}"
|
||||
+ $"&environmentId={Escape(request.EnvironmentId.Value)}"
|
||||
+ $"&protocolVersion={request.ProtocolVersion}"
|
||||
+ $"&pageSize={request.PageSize}"
|
||||
+ $"&excludeFull={request.ExcludeFull.ToString().ToLowerInvariant()}"
|
||||
+ (request.RegionId.HasValue ? $"®ionId={Escape(request.RegionId.Value.Value)}" : string.Empty)
|
||||
+ (request.Cursor is not null ? $"&cursor={Escape(request.Cursor)}" : string.Empty);
|
||||
return _transport.SendSafeAsync<BrowseSessionsResponse>(
|
||||
() => new HttpRequestMessage(HttpMethod.Get, query),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<RendezvousClientResult<IReadOnlyList<SessionListing>>> BrowseAllAsync(
|
||||
BrowseSessionsRequest request,
|
||||
int maximumPages = 100,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (request is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(request));
|
||||
}
|
||||
|
||||
if (maximumPages is < 1 or > 1000)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(maximumPages));
|
||||
}
|
||||
|
||||
List<SessionListing> items = [];
|
||||
string? cursor = request.Cursor;
|
||||
for (int page = 0; page < maximumPages; page++)
|
||||
{
|
||||
BrowseSessionsRequest pageRequest = new()
|
||||
{
|
||||
ContractVersion = request.ContractVersion,
|
||||
GameId = request.GameId,
|
||||
EnvironmentId = request.EnvironmentId,
|
||||
ProtocolVersion = request.ProtocolVersion,
|
||||
RegionId = request.RegionId,
|
||||
PageSize = request.PageSize,
|
||||
ExcludeFull = request.ExcludeFull,
|
||||
Cursor = cursor,
|
||||
};
|
||||
RendezvousClientResult<BrowseSessionsResponse> result = await BrowseAsync(
|
||||
pageRequest,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
if (!result.IsSuccess || result.Value is null)
|
||||
{
|
||||
return RendezvousClientResult.Failure<IReadOnlyList<SessionListing>>(
|
||||
result.Error,
|
||||
result.Message,
|
||||
result.RetryAfterSeconds);
|
||||
}
|
||||
|
||||
items.AddRange(result.Value.Items);
|
||||
cursor = result.Value.NextCursor;
|
||||
if (string.IsNullOrEmpty(cursor))
|
||||
{
|
||||
return RendezvousClientResult.Success<IReadOnlyList<SessionListing>>(items.AsReadOnly());
|
||||
}
|
||||
}
|
||||
|
||||
return RendezvousClientResult.Failure<IReadOnlyList<SessionListing>>(
|
||||
RendezvousErrorCode.CapacityExceeded,
|
||||
$"Browsing exceeded the configured {maximumPages}-page limit.");
|
||||
}
|
||||
|
||||
public Task<RendezvousClientResult<GetSessionResponse>> GetAsync(
|
||||
SessionListingId listingId,
|
||||
GameId gameId,
|
||||
EnvironmentId environmentId,
|
||||
uint protocolVersion,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
string query = $"v1/sessions/{listingId}?contractVersion={ContractLimits.ContractVersion}"
|
||||
+ $"&gameId={Escape(gameId.Value)}"
|
||||
+ $"&environmentId={Escape(environmentId.Value)}"
|
||||
+ $"&protocolVersion={protocolVersion}";
|
||||
return _transport.SendSafeAsync<GetSessionResponse>(
|
||||
() => new HttpRequestMessage(HttpMethod.Get, query),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private static string Escape(string value) => Uri.EscapeDataString(value ?? string.Empty);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
using FinalFactory.Rendezvous.Contracts;
|
||||
|
||||
namespace FinalFactory.Rendezvous.Client;
|
||||
|
||||
public enum LeaseMaintenanceStopReason
|
||||
{
|
||||
Cancelled = 1,
|
||||
Disposed = 2,
|
||||
LeaseLost = 3,
|
||||
Failed = 4,
|
||||
}
|
||||
|
||||
public sealed class LeaseMaintenanceResult
|
||||
{
|
||||
internal LeaseMaintenanceResult(LeaseMaintenanceStopReason reason, RendezvousErrorCode error)
|
||||
{
|
||||
Reason = reason;
|
||||
Error = error;
|
||||
}
|
||||
|
||||
public LeaseMaintenanceStopReason Reason { get; }
|
||||
public RendezvousErrorCode Error { get; }
|
||||
}
|
||||
|
||||
public sealed class SessionLeaseMaintainer : IAsyncDisposable
|
||||
{
|
||||
private readonly object _gate = new();
|
||||
private readonly IRendezvousPublisherClient _publisher;
|
||||
private readonly PublishedSession _session;
|
||||
private readonly string _publisherCredential;
|
||||
private readonly IRendezvousDelay _delay;
|
||||
private readonly CancellationTokenSource _disposeCancellation = new();
|
||||
private Task<LeaseMaintenanceResult>? _activeRun;
|
||||
private Task? _disposeTask;
|
||||
private bool _disposed;
|
||||
|
||||
internal SessionLeaseMaintainer(
|
||||
IRendezvousPublisherClient publisher,
|
||||
PublishedSession session,
|
||||
string publisherCredential,
|
||||
IRendezvousDelay? delay = null)
|
||||
{
|
||||
_publisher = publisher;
|
||||
_session = session;
|
||||
_publisherCredential = publisherCredential;
|
||||
_delay = delay ?? new SystemRendezvousDelay();
|
||||
}
|
||||
|
||||
public event EventHandler? LeaseLost;
|
||||
|
||||
public Task<LeaseMaintenanceResult> RunAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
throw new ObjectDisposedException(nameof(SessionLeaseMaintainer));
|
||||
}
|
||||
if (_activeRun is not null)
|
||||
{
|
||||
throw new InvalidOperationException("Lease maintenance is already running.");
|
||||
}
|
||||
|
||||
_activeRun = RunCoreAsync(cancellationToken);
|
||||
return _activeRun;
|
||||
}
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_disposeTask is not null)
|
||||
{
|
||||
return new(_disposeTask);
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
_disposeCancellation.Cancel();
|
||||
_disposeTask = FinishDisposeAsync(_activeRun);
|
||||
return new(_disposeTask);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task FinishDisposeAsync(Task<LeaseMaintenanceResult>? active)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (active is not null)
|
||||
{
|
||||
await active.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_disposeCancellation.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<LeaseMaintenanceResult> RunCoreAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await Task.Yield();
|
||||
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(
|
||||
cancellationToken,
|
||||
_disposeCancellation.Token);
|
||||
try
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
await _delay.DelayAsync(
|
||||
TimeSpan.FromSeconds(Math.Max(1, _session.LeaseRenewAfterSeconds)),
|
||||
linked.Token).ConfigureAwait(false);
|
||||
RendezvousClientResult<RenewLeaseResponse> renewed = await _publisher.RenewAsync(
|
||||
_session,
|
||||
_publisherCredential,
|
||||
linked.Token).ConfigureAwait(false);
|
||||
if (renewed.IsSuccess)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (renewed.Error is RendezvousErrorCode.NotFound
|
||||
or RendezvousErrorCode.Expired
|
||||
or RendezvousErrorCode.Forbidden
|
||||
or RendezvousErrorCode.AuthenticationRequired)
|
||||
{
|
||||
LeaseLost?.Invoke(this, EventArgs.Empty);
|
||||
return new(LeaseMaintenanceStopReason.LeaseLost, renewed.Error);
|
||||
}
|
||||
|
||||
return new(LeaseMaintenanceStopReason.Failed, renewed.Error);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (linked.IsCancellationRequested)
|
||||
{
|
||||
return new(
|
||||
_disposeCancellation.IsCancellationRequested
|
||||
? LeaseMaintenanceStopReason.Disposed
|
||||
: LeaseMaintenanceStopReason.Cancelled,
|
||||
RendezvousErrorCode.None);
|
||||
}
|
||||
finally
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_activeRun = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -174,6 +174,8 @@ public sealed class BrowseSessionsRequest
|
||||
public RegionId? RegionId { get; set; }
|
||||
public int PageSize { get; set; } = ContractLimits.BrowserPageMaxItems;
|
||||
|
||||
public bool ExcludeFull { get; set; }
|
||||
|
||||
public string? Cursor { get; set; }
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using FinalFactory.Rendezvous.Contracts;
|
||||
using FinalFactory.Rendezvous.Server.State;
|
||||
|
||||
namespace FinalFactory.Rendezvous.Server.Browser;
|
||||
|
||||
internal sealed class SessionBrowserCursorCodec : IDisposable
|
||||
{
|
||||
private const string Prefix = "rvc1";
|
||||
private readonly byte[] _key = RandomNumberGenerator.GetBytes(32);
|
||||
private bool _disposed;
|
||||
|
||||
public string Encode(VisibleListingQuery query, SessionListingId after, DateTimeOffset now)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
BrowserCursorPayload payload = new()
|
||||
{
|
||||
GameId = query.Scope.GameId.Value,
|
||||
EnvironmentId = query.Scope.EnvironmentId.Value,
|
||||
ProtocolVersion = query.ProtocolVersion,
|
||||
RegionId = query.RegionId?.Value,
|
||||
ExcludeFull = query.ExcludeFull,
|
||||
AfterListingId = after.ToString(),
|
||||
ExpiresAtUnixSeconds = now.AddMinutes(5).ToUnixTimeSeconds(),
|
||||
};
|
||||
string encoded = EncodeBytes(JsonSerializer.SerializeToUtf8Bytes(payload, ContractJson.Options));
|
||||
string content = $"{Prefix}.{encoded}";
|
||||
byte[] signature = HMACSHA256.HashData(_key, Encoding.ASCII.GetBytes(content));
|
||||
try
|
||||
{
|
||||
string cursor = $"{content}.{EncodeBytes(signature)}";
|
||||
return ContractValidation.IsCursorValid(cursor)
|
||||
? cursor
|
||||
: throw new InvalidOperationException("The browser cursor exceeds its contract limit.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
CryptographicOperations.ZeroMemory(signature);
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryDecode(
|
||||
string? cursor,
|
||||
TenantScope scope,
|
||||
uint protocolVersion,
|
||||
RegionId? regionId,
|
||||
bool excludeFull,
|
||||
DateTimeOffset now,
|
||||
out SessionListingId? after)
|
||||
{
|
||||
after = null;
|
||||
if (cursor is null)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (_disposed || !ContractValidation.IsCursorValid(cursor))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string[] segments = cursor.Split('.');
|
||||
if (segments.Length != 3 || !string.Equals(segments[0], Prefix, StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
byte[] expected = HMACSHA256.HashData(
|
||||
_key,
|
||||
Encoding.ASCII.GetBytes($"{segments[0]}.{segments[1]}"));
|
||||
if (!TryDecodeBytes(segments[2], out byte[] supplied))
|
||||
{
|
||||
CryptographicOperations.ZeroMemory(expected);
|
||||
return false;
|
||||
}
|
||||
|
||||
bool validSignature = supplied.Length == expected.Length
|
||||
&& CryptographicOperations.FixedTimeEquals(supplied, expected);
|
||||
CryptographicOperations.ZeroMemory(supplied);
|
||||
CryptographicOperations.ZeroMemory(expected);
|
||||
if (!validSignature || !TryDecodeBytes(segments[1], out byte[] encodedPayload))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
BrowserCursorPayload? payload;
|
||||
try
|
||||
{
|
||||
payload = JsonSerializer.Deserialize<BrowserCursorPayload>(
|
||||
encodedPayload,
|
||||
ContractJson.Options);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
payload = null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
CryptographicOperations.ZeroMemory(encodedPayload);
|
||||
}
|
||||
|
||||
if (payload is null
|
||||
|| payload.ExpiresAtUnixSeconds <= now.ToUnixTimeSeconds()
|
||||
|| !string.Equals(payload.GameId, scope.GameId.Value, StringComparison.Ordinal)
|
||||
|| !string.Equals(payload.EnvironmentId, scope.EnvironmentId.Value, StringComparison.Ordinal)
|
||||
|| payload.ProtocolVersion != protocolVersion
|
||||
|| !string.Equals(payload.RegionId, regionId?.Value, StringComparison.Ordinal)
|
||||
|| payload.ExcludeFull != excludeFull
|
||||
|| !SessionListingId.TryParse(payload.AfterListingId, out SessionListingId listingId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
after = listingId;
|
||||
return true;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (!_disposed)
|
||||
{
|
||||
_disposed = true;
|
||||
CryptographicOperations.ZeroMemory(_key);
|
||||
}
|
||||
}
|
||||
|
||||
public override string ToString() => "[SessionBrowserCursorCodec: key and cursors redacted]";
|
||||
|
||||
private static string EncodeBytes(ReadOnlySpan<byte> bytes) => Convert
|
||||
.ToBase64String(bytes)
|
||||
.TrimEnd('=')
|
||||
.Replace('+', '-')
|
||||
.Replace('/', '_');
|
||||
|
||||
private static bool TryDecodeBytes(string value, out byte[] bytes)
|
||||
{
|
||||
bytes = [];
|
||||
if (string.IsNullOrEmpty(value)
|
||||
|| value.Any(static character =>
|
||||
character is not (>= 'A' and <= 'Z')
|
||||
and not (>= 'a' and <= 'z')
|
||||
and not (>= '0' and <= '9')
|
||||
and not '-'
|
||||
and not '_'))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string padded = value.Replace('-', '+').Replace('_', '/');
|
||||
padded += (padded.Length % 4) switch { 0 => "", 2 => "==", 3 => "=", _ => "!" };
|
||||
try
|
||||
{
|
||||
bytes = Convert.FromBase64String(padded);
|
||||
return true;
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class BrowserCursorPayload
|
||||
{
|
||||
[JsonRequired]
|
||||
public string GameId { get; set; } = string.Empty;
|
||||
[JsonRequired]
|
||||
public string EnvironmentId { get; set; } = string.Empty;
|
||||
[JsonRequired]
|
||||
public uint ProtocolVersion { get; set; }
|
||||
public string? RegionId { get; set; }
|
||||
[JsonRequired]
|
||||
public bool ExcludeFull { get; set; }
|
||||
[JsonRequired]
|
||||
public string AfterListingId { get; set; } = string.Empty;
|
||||
[JsonRequired]
|
||||
public long ExpiresAtUnixSeconds { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
using System.Text.Json;
|
||||
using FinalFactory.Rendezvous.Contracts;
|
||||
using FinalFactory.Rendezvous.Server.State;
|
||||
|
||||
namespace FinalFactory.Rendezvous.Server.Browser;
|
||||
|
||||
internal sealed record BrowserServiceResult<T>(RendezvousErrorCode Error, T? Value = default)
|
||||
{
|
||||
public bool Succeeded => Error == RendezvousErrorCode.None;
|
||||
}
|
||||
|
||||
internal sealed class SessionBrowserService(
|
||||
IEphemeralRendezvousStore store,
|
||||
SessionBrowserCursorCodec cursors,
|
||||
IWallClock clock)
|
||||
{
|
||||
public BrowserServiceResult<BrowseSessionsResponse> Browse(
|
||||
BrowseSessionsRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
RendezvousErrorCode validation = Validate(request);
|
||||
if (validation != RendezvousErrorCode.None)
|
||||
{
|
||||
return new(validation);
|
||||
}
|
||||
|
||||
TenantScope scope = new(request.GameId, request.EnvironmentId);
|
||||
if (!cursors.TryDecode(
|
||||
request.Cursor,
|
||||
scope,
|
||||
request.ProtocolVersion,
|
||||
request.RegionId,
|
||||
request.ExcludeFull,
|
||||
clock.UtcNow,
|
||||
out SessionListingId? after))
|
||||
{
|
||||
return new(RendezvousErrorCode.InvalidRequest);
|
||||
}
|
||||
|
||||
VisibleListingQuery query = new(
|
||||
scope,
|
||||
request.ProtocolVersion,
|
||||
request.RegionId,
|
||||
request.PageSize + 1,
|
||||
after,
|
||||
request.ExcludeFull);
|
||||
StoreResult<IReadOnlyList<StoredListing>> found = store.BrowseVisibleListings(
|
||||
query,
|
||||
cancellationToken);
|
||||
if (!found.Succeeded || found.Value is null)
|
||||
{
|
||||
return new(found.Code == StoreResultCode.ServiceUnavailable
|
||||
? RendezvousErrorCode.ServiceUnavailable
|
||||
: RendezvousErrorCode.InternalError);
|
||||
}
|
||||
|
||||
List<SessionListing> items = found.Value
|
||||
.Take(request.PageSize)
|
||||
.Select(ToContract)
|
||||
.ToList();
|
||||
bool hasMore = found.Value.Count > request.PageSize;
|
||||
while (items.Count > 0)
|
||||
{
|
||||
string? nextCursor = hasMore
|
||||
? cursors.Encode(query, items[^1].ListingId, clock.UtcNow)
|
||||
: null;
|
||||
BrowseSessionsResponse response = new() { Items = items, NextCursor = nextCursor };
|
||||
if (JsonSerializer.SerializeToUtf8Bytes(response, ContractJson.Options).Length
|
||||
<= ContractLimits.BrowserResponseMaxBytes)
|
||||
{
|
||||
return new(RendezvousErrorCode.None, response);
|
||||
}
|
||||
|
||||
items.RemoveAt(items.Count - 1);
|
||||
hasMore = true;
|
||||
}
|
||||
|
||||
return new(RendezvousErrorCode.None, new BrowseSessionsResponse());
|
||||
}
|
||||
|
||||
public BrowserServiceResult<GetSessionResponse> Get(
|
||||
SessionListingId listingId,
|
||||
GameId gameId,
|
||||
EnvironmentId environmentId,
|
||||
uint protocolVersion,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (listingId.Value == Guid.Empty
|
||||
|| string.IsNullOrEmpty(gameId.Value)
|
||||
|| string.IsNullOrEmpty(environmentId.Value)
|
||||
|| protocolVersion == 0)
|
||||
{
|
||||
return new(RendezvousErrorCode.InvalidRequest);
|
||||
}
|
||||
|
||||
StoreResult<StoredListing> found = store.GetListing(listingId, true, cancellationToken);
|
||||
if (!found.Succeeded || found.Value is null)
|
||||
{
|
||||
return new(found.Code == StoreResultCode.ServiceUnavailable
|
||||
? RendezvousErrorCode.ServiceUnavailable
|
||||
: RendezvousErrorCode.NotFound);
|
||||
}
|
||||
|
||||
StoredListing listing = found.Value;
|
||||
if (listing.Definition.Scope != new TenantScope(gameId, environmentId)
|
||||
|| listing.Definition.ProtocolVersion != protocolVersion)
|
||||
{
|
||||
return new(RendezvousErrorCode.NotFound);
|
||||
}
|
||||
|
||||
return new(RendezvousErrorCode.None, new GetSessionResponse
|
||||
{
|
||||
Session = ToContract(listing),
|
||||
});
|
||||
}
|
||||
|
||||
private static RendezvousErrorCode Validate(BrowseSessionsRequest request)
|
||||
{
|
||||
RendezvousErrorCode version = ContractValidation.ValidateContractVersion(request.ContractVersion);
|
||||
if (version != RendezvousErrorCode.None)
|
||||
{
|
||||
return version;
|
||||
}
|
||||
|
||||
return string.IsNullOrEmpty(request.GameId.Value)
|
||||
|| string.IsNullOrEmpty(request.EnvironmentId.Value)
|
||||
|| request.ProtocolVersion == 0
|
||||
|| (request.RegionId.HasValue && string.IsNullOrEmpty(request.RegionId.Value.Value))
|
||||
|| !ContractValidation.IsPageSizeValid(request.PageSize)
|
||||
|| !ContractValidation.IsCursorValid(request.Cursor)
|
||||
? RendezvousErrorCode.InvalidRequest
|
||||
: RendezvousErrorCode.None;
|
||||
}
|
||||
|
||||
private static SessionListing ToContract(StoredListing stored) => new()
|
||||
{
|
||||
ListingId = stored.Definition.ListingId,
|
||||
GameId = stored.Definition.Scope.GameId,
|
||||
EnvironmentId = stored.Definition.Scope.EnvironmentId,
|
||||
RegionId = stored.Definition.RegionId,
|
||||
ProtocolVersion = stored.Definition.ProtocolVersion,
|
||||
BuildVersion = stored.Definition.BuildVersion,
|
||||
DisplayName = stored.Definition.DisplayName,
|
||||
Visibility = stored.Definition.Visibility,
|
||||
PublisherTrustMode = stored.Definition.TrustMode,
|
||||
Capacity = new()
|
||||
{
|
||||
CurrentPlayers = stored.Definition.CurrentPlayers,
|
||||
MaximumPlayers = stored.Definition.MaximumPlayers,
|
||||
},
|
||||
Metadata = stored.Definition.Metadata.ToDictionary(
|
||||
static item => item.Key,
|
||||
static item => item.Value,
|
||||
StringComparer.Ordinal),
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using FinalFactory.Rendezvous.Contracts;
|
||||
using FinalFactory.Rendezvous.Server.Browser;
|
||||
using FinalFactory.Rendezvous.Server.Provisioning;
|
||||
using FinalFactory.Rendezvous.Server.Sessions;
|
||||
using FinalFactory.Rendezvous.Server.State;
|
||||
@@ -55,11 +56,14 @@ internal static class ContractEndpoints
|
||||
.WithName("DeleteSession");
|
||||
sessions.MapGet("/", BrowseSessions)
|
||||
.Produces<BrowseSessionsResponse>()
|
||||
.Produces<ApiError>(NotImplementedStatus)
|
||||
.Produces<ApiError>(StatusCodes.Status400BadRequest)
|
||||
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
|
||||
.WithName("BrowseSessions");
|
||||
sessions.MapGet("/{listingId}", GetSession)
|
||||
.Produces<GetSessionResponse>()
|
||||
.Produces<ApiError>(NotImplementedStatus)
|
||||
.Produces<ApiError>(StatusCodes.Status400BadRequest)
|
||||
.Produces<ApiError>(StatusCodes.Status404NotFound)
|
||||
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
|
||||
.WithName("GetSession");
|
||||
sessions.MapGet("/{listingId}/join-attempts", BrowseHostJoinAttempts)
|
||||
.Produces<BrowseHostJoinAttemptsResponse>()
|
||||
@@ -200,9 +204,64 @@ internal static class ContractEndpoints
|
||||
[FromQuery] uint protocolVersion,
|
||||
[FromQuery] string? regionId,
|
||||
[FromQuery] int? pageSize,
|
||||
[FromQuery] string? cursor) => NotImplemented();
|
||||
[FromQuery] bool? excludeFull,
|
||||
[FromQuery] string? cursor,
|
||||
[FromServices] SessionBrowserService browser,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!GameId.TryParse(gameId, out GameId parsedGameId)
|
||||
|| !EnvironmentId.TryParse(environmentId, out EnvironmentId parsedEnvironmentId)
|
||||
|| (regionId is not null && !RegionId.TryParse(regionId, out _)))
|
||||
{
|
||||
return Error(RendezvousErrorCode.InvalidRequest);
|
||||
}
|
||||
|
||||
private static IResult GetSession(SessionListingId listingId) => NotImplemented();
|
||||
BrowserServiceResult<BrowseSessionsResponse> result = browser.Browse(new()
|
||||
{
|
||||
ContractVersion = contractVersion,
|
||||
GameId = parsedGameId,
|
||||
EnvironmentId = parsedEnvironmentId,
|
||||
ProtocolVersion = protocolVersion,
|
||||
RegionId = regionId is null ? null : new RegionId(regionId),
|
||||
PageSize = pageSize ?? ContractLimits.BrowserPageMaxItems,
|
||||
ExcludeFull = excludeFull ?? false,
|
||||
Cursor = cursor,
|
||||
}, cancellationToken);
|
||||
return result.Succeeded && result.Value is not null
|
||||
? Results.Ok(result.Value)
|
||||
: Error(result.Error);
|
||||
}
|
||||
|
||||
private static IResult GetSession(
|
||||
SessionListingId listingId,
|
||||
[FromQuery] int contractVersion,
|
||||
[FromQuery] string gameId,
|
||||
[FromQuery] string environmentId,
|
||||
[FromQuery] uint protocolVersion,
|
||||
[FromServices] SessionBrowserService browser,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (ContractValidation.ValidateContractVersion(contractVersion) != RendezvousErrorCode.None)
|
||||
{
|
||||
return Error(RendezvousErrorCode.UnsupportedContractVersion);
|
||||
}
|
||||
|
||||
if (!GameId.TryParse(gameId, out GameId parsedGameId)
|
||||
|| !EnvironmentId.TryParse(environmentId, out EnvironmentId parsedEnvironmentId))
|
||||
{
|
||||
return Error(RendezvousErrorCode.InvalidRequest);
|
||||
}
|
||||
|
||||
BrowserServiceResult<GetSessionResponse> result = browser.Get(
|
||||
listingId,
|
||||
parsedGameId,
|
||||
parsedEnvironmentId,
|
||||
protocolVersion,
|
||||
cancellationToken);
|
||||
return result.Succeeded && result.Value is not null
|
||||
? Results.Ok(result.Value)
|
||||
: Error(result.Error);
|
||||
}
|
||||
|
||||
private static IResult BrowseHostJoinAttempts(
|
||||
SessionListingId listingId,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Net;
|
||||
using FinalFactory.Rendezvous.Contracts;
|
||||
using FinalFactory.Rendezvous.Server.Browser;
|
||||
using FinalFactory.Rendezvous.Server.Http;
|
||||
using FinalFactory.Rendezvous.Server.Provisioning;
|
||||
using FinalFactory.Rendezvous.Server.Sessions;
|
||||
@@ -119,6 +120,8 @@ else
|
||||
builder.Services.AddSingleton<ISessionCapabilityService>(sessionCapabilities);
|
||||
builder.Services.AddSingleton(SessionLeaseTiming.From(stateOptions));
|
||||
builder.Services.AddSingleton<SessionLeaseService>();
|
||||
builder.Services.AddSingleton<SessionBrowserCursorCodec>();
|
||||
builder.Services.AddSingleton<SessionBrowserService>();
|
||||
builder.Services.AddSingleton(new ProvisioningReadiness(true));
|
||||
}
|
||||
|
||||
|
||||
@@ -223,7 +223,9 @@ internal sealed record VisibleListingQuery(
|
||||
TenantScope Scope,
|
||||
uint ProtocolVersion,
|
||||
RegionId? RegionId,
|
||||
int MaximumResults = ContractLimits.BrowserPageMaxItems);
|
||||
int MaximumResults = ContractLimits.BrowserPageMaxItems,
|
||||
SessionListingId? AfterListingId = null,
|
||||
bool ExcludeFull = false);
|
||||
|
||||
internal enum AttemptPeerRole
|
||||
{
|
||||
|
||||
@@ -301,7 +301,7 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
||||
|| query.ProtocolVersion == 0
|
||||
|| (query.RegionId.HasValue && string.IsNullOrEmpty(query.RegionId.Value.Value))
|
||||
|| query.MaximumResults <= 0
|
||||
|| query.MaximumResults > ContractLimits.BrowserPageMaxItems)
|
||||
|| query.MaximumResults > ContractLimits.BrowserPageMaxItems + 1)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(query));
|
||||
}
|
||||
@@ -311,6 +311,10 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
||||
&& entry.Definition.ProtocolVersion == query.ProtocolVersion
|
||||
&& entry.Definition.Visibility == ListingVisibility.Public
|
||||
&& (!query.RegionId.HasValue || entry.Definition.RegionId == query.RegionId.Value)
|
||||
&& (!query.AfterListingId.HasValue
|
||||
|| entry.Definition.ListingId.Value.CompareTo(query.AfterListingId.Value.Value) > 0)
|
||||
&& (!query.ExcludeFull
|
||||
|| entry.Definition.CurrentPlayers < entry.Definition.MaximumPlayers)
|
||||
&& _presence.ContainsKey(entry.Definition.HostPresenceHandle))
|
||||
.OrderBy(static entry => entry.Definition.ListingId.Value)
|
||||
.Take(query.MaximumResults)
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
using System.Text.Json;
|
||||
using FinalFactory.Rendezvous.Contracts;
|
||||
using FinalFactory.Rendezvous.Server.Browser;
|
||||
using FinalFactory.Rendezvous.Server.State;
|
||||
|
||||
namespace FinalFactory.Rendezvous.Tests.Browser;
|
||||
|
||||
public sealed class SessionBrowserServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public void ListEnforcesTenantProtocolPresenceVisibilityAndAvailabilityFilters()
|
||||
{
|
||||
using SessionBrowserFixture fixture = new();
|
||||
StoredListing eligible = fixture.Add();
|
||||
fixture.Add(scope: new(new("other-game"), fixture.Scope.EnvironmentId));
|
||||
fixture.Add(scope: new(fixture.Scope.GameId, new("other-env")));
|
||||
fixture.Add(protocolVersion: 8);
|
||||
fixture.Add(regionId: new("us-east"));
|
||||
fixture.Add(visibility: ListingVisibility.Unlisted);
|
||||
fixture.Add(fresh: false);
|
||||
fixture.Add(currentPlayers: 8, maximumPlayers: 8);
|
||||
BrowseSessionsRequest request = fixture.Request();
|
||||
request.ExcludeFull = true;
|
||||
|
||||
BrowserServiceResult<BrowseSessionsResponse> result = fixture.Browser.Browse(request);
|
||||
|
||||
Assert.True(result.Succeeded);
|
||||
Assert.Collection(result.Value!.Items, item => Assert.Equal(eligible.Definition.ListingId, item.ListingId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnguessableIdRetrievalAllowsFreshUnlistedOnlyWithinExactScope()
|
||||
{
|
||||
using SessionBrowserFixture fixture = new();
|
||||
StoredListing unlisted = fixture.Add(visibility: ListingVisibility.Unlisted);
|
||||
|
||||
Assert.True(fixture.Browser.Get(
|
||||
unlisted.Definition.ListingId,
|
||||
fixture.Scope.GameId,
|
||||
fixture.Scope.EnvironmentId,
|
||||
7).Succeeded);
|
||||
Assert.Equal(RendezvousErrorCode.NotFound, fixture.Browser.Get(
|
||||
unlisted.Definition.ListingId,
|
||||
new("other-game"),
|
||||
fixture.Scope.EnvironmentId,
|
||||
7).Error);
|
||||
Assert.Equal(RendezvousErrorCode.NotFound, fixture.Browser.Get(
|
||||
unlisted.Definition.ListingId,
|
||||
fixture.Scope.GameId,
|
||||
fixture.Scope.EnvironmentId,
|
||||
8).Error);
|
||||
fixture.Clock.Advance(TimeSpan.FromSeconds(20));
|
||||
Assert.Equal(RendezvousErrorCode.NotFound, fixture.Browser.Get(
|
||||
unlisted.Definition.ListingId,
|
||||
fixture.Scope.GameId,
|
||||
fixture.Scope.EnvironmentId,
|
||||
7).Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void KeysetCursorReturnsStableRecordsOnceAndRejectsTamperingOrRescoping()
|
||||
{
|
||||
using SessionBrowserFixture fixture = new();
|
||||
for (int index = 0; index < 7; index++)
|
||||
{
|
||||
fixture.Add();
|
||||
}
|
||||
|
||||
BrowseSessionsRequest request = fixture.Request(pageSize: 2);
|
||||
List<SessionListingId> seen = [];
|
||||
do
|
||||
{
|
||||
BrowseSessionsResponse page = fixture.Browser.Browse(request).Value!;
|
||||
seen.AddRange(page.Items.Select(static item => item.ListingId));
|
||||
request.Cursor = page.NextCursor;
|
||||
}
|
||||
while (request.Cursor is not null);
|
||||
|
||||
Assert.Equal(7, seen.Count);
|
||||
Assert.Equal(7, seen.Distinct().Count());
|
||||
Assert.Equal(seen.OrderBy(static id => id.Value), seen);
|
||||
|
||||
BrowseSessionsRequest tampered = fixture.Request(pageSize: 2);
|
||||
tampered.Cursor = fixture.Browser.Browse(tampered).Value!.NextCursor + "A";
|
||||
Assert.Equal(RendezvousErrorCode.InvalidRequest, fixture.Browser.Browse(tampered).Error);
|
||||
BrowseSessionsRequest rescoped = fixture.Request(pageSize: 2);
|
||||
rescoped.Cursor = fixture.Browser.Browse(fixture.Request(pageSize: 2)).Value!.NextCursor;
|
||||
rescoped.ExcludeFull = true;
|
||||
Assert.Equal(RendezvousErrorCode.InvalidRequest, fixture.Browser.Browse(rescoped).Error);
|
||||
|
||||
BrowseSessionsRequest expired = fixture.Request(pageSize: 2);
|
||||
expired.Cursor = fixture.Browser.Browse(expired).Value!.NextCursor;
|
||||
fixture.Clock.Advance(TimeSpan.FromMinutes(5));
|
||||
Assert.Equal(RendezvousErrorCode.InvalidRequest, fixture.Browser.Browse(expired).Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResponseByteBudgetTrimsLargePagesAndContinuesWithCursor()
|
||||
{
|
||||
using SessionBrowserFixture fixture = new();
|
||||
Dictionary<string, string> metadata = Enumerable.Range(0, 14).ToDictionary(
|
||||
static index => $"key-{index}",
|
||||
static index => new string((char)('a' + index % 26), 256),
|
||||
EqualityComparer<string>.Default);
|
||||
for (int index = 0; index < 100; index++)
|
||||
{
|
||||
fixture.Add(metadata: metadata);
|
||||
}
|
||||
|
||||
BrowseSessionsResponse response = fixture.Browser.Browse(fixture.Request()).Value!;
|
||||
int encodedBytes = JsonSerializer.SerializeToUtf8Bytes(response, ContractJson.Options).Length;
|
||||
|
||||
Assert.InRange(encodedBytes, 1, ContractLimits.BrowserResponseMaxBytes);
|
||||
Assert.NotEmpty(response.Items);
|
||||
Assert.NotNull(response.NextCursor);
|
||||
Assert.True(response.Items.Count < 100);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PresentationMetadataIsJsonEscapedAndResponseHasNoConnectionSecrets()
|
||||
{
|
||||
using SessionBrowserFixture fixture = new();
|
||||
fixture.Add(metadata: new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["mode"] = "co-op",
|
||||
["map"] = "<script>alert(1)</script>",
|
||||
});
|
||||
|
||||
BrowseSessionsResponse response = fixture.Browser.Browse(fixture.Request()).Value!;
|
||||
string json = JsonSerializer.Serialize(response, ContractJson.Options);
|
||||
|
||||
Assert.DoesNotContain("<script>", json, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("endpoint", json, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("token", json, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("capability", json, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Equal("<script>alert(1)</script>", Assert.Single(response.Items).Metadata["map"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RevokedListingDisappearsBeforeAnotherReadPathCanObserveIt()
|
||||
{
|
||||
using SessionBrowserFixture fixture = new();
|
||||
StoredListing listing = fixture.Add();
|
||||
fixture.Store.RevokeListing(listing.Definition.ListingId);
|
||||
|
||||
Assert.Empty(fixture.Browser.Browse(fixture.Request()).Value!.Items);
|
||||
Assert.Equal(RendezvousErrorCode.NotFound, fixture.Browser.Get(
|
||||
listing.Definition.ListingId,
|
||||
fixture.Scope.GameId,
|
||||
fixture.Scope.EnvironmentId,
|
||||
7).Error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using FinalFactory.Rendezvous.Contracts;
|
||||
using FinalFactory.Rendezvous.Server.Browser;
|
||||
using FinalFactory.Rendezvous.Server.State;
|
||||
using FinalFactory.Rendezvous.Tests.State;
|
||||
|
||||
namespace FinalFactory.Rendezvous.Tests.Browser;
|
||||
|
||||
internal sealed class SessionBrowserFixture : IDisposable
|
||||
{
|
||||
private readonly EphemeralStateFixture _state = new();
|
||||
|
||||
public SessionBrowserFixture()
|
||||
{
|
||||
Cursors = new();
|
||||
Browser = new(_state.Store, Cursors, _state.Clock);
|
||||
}
|
||||
|
||||
public InMemoryEphemeralRendezvousStore Store => _state.Store;
|
||||
public ManualRendezvousClock Clock => _state.Clock;
|
||||
public SessionBrowserCursorCodec Cursors { get; }
|
||||
public SessionBrowserService Browser { get; }
|
||||
public TenantScope Scope => _state.Scope;
|
||||
|
||||
public StoredListing Add(
|
||||
TenantScope? scope = null,
|
||||
uint protocolVersion = 7,
|
||||
RegionId? regionId = null,
|
||||
ListingVisibility visibility = ListingVisibility.Public,
|
||||
bool fresh = true,
|
||||
int currentPlayers = 1,
|
||||
int maximumPlayers = 8,
|
||||
IReadOnlyDictionary<string, string>? metadata = null)
|
||||
{
|
||||
CreateListingCommand seed = _state.ListingCommand();
|
||||
CreateListingCommand command = seed with
|
||||
{
|
||||
Listing = seed.Listing with
|
||||
{
|
||||
Scope = scope ?? Scope,
|
||||
ProtocolVersion = protocolVersion,
|
||||
RegionId = regionId ?? seed.Listing.RegionId,
|
||||
Visibility = visibility,
|
||||
CurrentPlayers = currentPlayers,
|
||||
MaximumPlayers = maximumPlayers,
|
||||
Metadata = metadata ?? seed.Listing.Metadata,
|
||||
},
|
||||
};
|
||||
StoredListing listing = Store.CreateListing(command).Value!;
|
||||
if (fresh)
|
||||
{
|
||||
listing = Store.BindHostPresence(new(
|
||||
command.Listing.HostPresenceHandle,
|
||||
command.Listing.HostPresenceFingerprint,
|
||||
EphemeralStateFixture.PublicEndpoint(40_000),
|
||||
null)).Value!;
|
||||
}
|
||||
|
||||
return listing;
|
||||
}
|
||||
|
||||
public BrowseSessionsRequest Request(int pageSize = 100) => new()
|
||||
{
|
||||
GameId = Scope.GameId,
|
||||
EnvironmentId = Scope.EnvironmentId,
|
||||
ProtocolVersion = 7,
|
||||
RegionId = new("eu-central"),
|
||||
PageSize = pageSize,
|
||||
};
|
||||
|
||||
public void Dispose() => Cursors.Dispose();
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using FinalFactory.Rendezvous.Client;
|
||||
using FinalFactory.Rendezvous.Contracts;
|
||||
|
||||
namespace FinalFactory.Rendezvous.Tests.Client;
|
||||
|
||||
public sealed class RendezvousClientBehaviorTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task RegistrationRetriesWithTheSameIdempotentPayloadAndDisposesResponses()
|
||||
{
|
||||
TrackingContent unavailable = JsonContent(new ApiError
|
||||
{
|
||||
Code = RendezvousErrorCode.ServiceUnavailable,
|
||||
Message = "try later",
|
||||
RetryAfterSeconds = 1,
|
||||
});
|
||||
TrackingContent created = JsonContent(CreateRegistrationResponse());
|
||||
ScriptedHandler handler = new(
|
||||
Response(HttpStatusCode.ServiceUnavailable, unavailable),
|
||||
Response(HttpStatusCode.Created, created),
|
||||
new HttpResponseMessage(HttpStatusCode.NoContent));
|
||||
using HttpClient httpClient = new(handler) { BaseAddress = new("http://rendezvous.test/") };
|
||||
RegisterSessionRequest request = CreateRegistrationRequest("stable-idempotency-key");
|
||||
RecordingDelay delay = new(() => request.DisplayName = "mutated during retry delay");
|
||||
RendezvousPublisherClient publisher = new(
|
||||
httpClient,
|
||||
new RendezvousClientOptions { JitterRatio = 0 },
|
||||
delay);
|
||||
|
||||
RendezvousClientResult<PublishedSession> result = await publisher.RegisterAsync(
|
||||
request,
|
||||
"publisher-credential");
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal(2, handler.RequestBodies.Count);
|
||||
Assert.Equal(handler.RequestBodies[0], handler.RequestBodies[1]);
|
||||
Assert.Contains("stable-idempotency-key", handler.RequestBodies[0], StringComparison.Ordinal);
|
||||
Assert.Equal(TimeSpan.FromSeconds(1), Assert.Single(delay.Delays));
|
||||
Assert.True(unavailable.IsDisposed);
|
||||
Assert.True(created.IsDisposed);
|
||||
|
||||
using HttpResponseMessage stillOwnedByCaller = await httpClient.GetAsync("health");
|
||||
Assert.Equal(HttpStatusCode.NoContent, stillOwnedByCaller.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateUsesTheLeaseWithoutMutatingTheCallersRequest()
|
||||
{
|
||||
ScriptedHandler handler = new(
|
||||
Response(HttpStatusCode.Created, JsonContent(CreateRegistrationResponse())),
|
||||
new HttpResponseMessage(HttpStatusCode.NoContent));
|
||||
using HttpClient httpClient = new(handler) { BaseAddress = new("http://rendezvous.test/") };
|
||||
RendezvousPublisherClient publisher = new(httpClient);
|
||||
PublishedSession session = AssertSuccess(await publisher.RegisterAsync(
|
||||
CreateRegistrationRequest("update-idempotency-key"),
|
||||
"publisher-credential"));
|
||||
UpdateSessionRequest update = new()
|
||||
{
|
||||
LeaseToken = "caller-placeholder",
|
||||
BuildVersion = "2.0.0",
|
||||
DisplayName = "updated",
|
||||
Capacity = new() { CurrentPlayers = 2, MaximumPlayers = 4 },
|
||||
Metadata = new() { ["mode"] = "online-coop" },
|
||||
};
|
||||
|
||||
RendezvousClientResult<bool> result = await publisher.UpdateAsync(
|
||||
session,
|
||||
update,
|
||||
"publisher-credential");
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal("caller-placeholder", update.LeaseToken);
|
||||
Assert.Contains("lease-token", handler.RequestBodies[1], StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("caller-placeholder", handler.RequestBodies[1], StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GatewayFailureIsRetriedForSafeBrowserReads()
|
||||
{
|
||||
ScriptedHandler handler = new(
|
||||
new HttpResponseMessage(HttpStatusCode.BadGateway),
|
||||
Response(HttpStatusCode.OK, JsonContent(new BrowseSessionsResponse())));
|
||||
using HttpClient httpClient = new(handler) { BaseAddress = new("http://rendezvous.test/") };
|
||||
RecordingDelay delay = new();
|
||||
RendezvousSessionBrowserClient browser = new(
|
||||
httpClient,
|
||||
new RendezvousClientOptions { JitterRatio = 0 },
|
||||
delay);
|
||||
|
||||
RendezvousClientResult<BrowseSessionsResponse> result = await browser.BrowseAsync(new()
|
||||
{
|
||||
GameId = new("space-game"),
|
||||
EnvironmentId = new("production"),
|
||||
ProtocolVersion = 7,
|
||||
});
|
||||
|
||||
Assert.True(result.IsSuccess, result.Message);
|
||||
Assert.Equal(2, handler.RequestUris.Count);
|
||||
Assert.Equal(TimeSpan.FromMilliseconds(200), Assert.Single(delay.Delays));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SuccessResultRequiresAValue()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(() => RendezvousClientResult.Success<string>(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BrowseAllFollowsCursorsWithoutMutatingTheCallersRequest()
|
||||
{
|
||||
ScriptedHandler handler = new(
|
||||
Response(HttpStatusCode.OK, JsonContent(new BrowseSessionsResponse
|
||||
{
|
||||
Items = [CreateListing("00000000-0000-0000-0000-000000000001")],
|
||||
NextCursor = "next page+token",
|
||||
})),
|
||||
Response(HttpStatusCode.OK, JsonContent(new BrowseSessionsResponse
|
||||
{
|
||||
Items = [CreateListing("00000000-0000-0000-0000-000000000002")],
|
||||
})));
|
||||
using HttpClient httpClient = new(handler) { BaseAddress = new("http://rendezvous.test/") };
|
||||
RendezvousSessionBrowserClient browser = new(httpClient);
|
||||
BrowseSessionsRequest request = new()
|
||||
{
|
||||
GameId = new("space-game"),
|
||||
EnvironmentId = new("production"),
|
||||
ProtocolVersion = 7,
|
||||
PageSize = 1,
|
||||
};
|
||||
|
||||
RendezvousClientResult<IReadOnlyList<SessionListing>> result = await browser.BrowseAllAsync(request);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal(2, result.Value!.Count);
|
||||
Assert.Null(request.Cursor);
|
||||
Assert.DoesNotContain("cursor=", handler.RequestUris[0].Query, StringComparison.Ordinal);
|
||||
Assert.Contains("cursor=next%20page%2Btoken", handler.RequestUris[1].Query, StringComparison.Ordinal);
|
||||
Assert.Contains("gameId=space-game", handler.RequestUris[0].Query, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LeaseMaintainerReportsLeaseLoss()
|
||||
{
|
||||
ScriptedHandler handler = new(
|
||||
Response(HttpStatusCode.Created, JsonContent(CreateRegistrationResponse())),
|
||||
Response(HttpStatusCode.Gone, JsonContent(new ApiError
|
||||
{
|
||||
Code = RendezvousErrorCode.Expired,
|
||||
Message = "lease expired",
|
||||
})));
|
||||
using HttpClient httpClient = new(handler) { BaseAddress = new("http://rendezvous.test/") };
|
||||
RecordingDelay delay = new();
|
||||
RendezvousPublisherClient publisher = new(httpClient, delay: delay);
|
||||
PublishedSession session = AssertSuccess(await publisher.RegisterAsync(
|
||||
CreateRegistrationRequest("lease-loss-key"),
|
||||
"publisher-credential"));
|
||||
await using SessionLeaseMaintainer maintainer = publisher.CreateLeaseMaintainer(
|
||||
session,
|
||||
"publisher-credential");
|
||||
bool eventRaised = false;
|
||||
maintainer.LeaseLost += (_, _) => eventRaised = true;
|
||||
|
||||
LeaseMaintenanceResult result = await maintainer.RunAsync();
|
||||
|
||||
Assert.Equal(LeaseMaintenanceStopReason.LeaseLost, result.Reason);
|
||||
Assert.Equal(RendezvousErrorCode.Expired, result.Error);
|
||||
Assert.True(eventRaised);
|
||||
Assert.Equal(TimeSpan.FromSeconds(15), Assert.Single(delay.Delays));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DisposingLeaseMaintainerCancelsItsWaitAndDoesNotRenew()
|
||||
{
|
||||
ScriptedHandler handler = new(
|
||||
Response(HttpStatusCode.Created, JsonContent(CreateRegistrationResponse())));
|
||||
using HttpClient httpClient = new(handler) { BaseAddress = new("http://rendezvous.test/") };
|
||||
BlockingDelay delay = new();
|
||||
RendezvousPublisherClient publisher = new(httpClient, delay: delay);
|
||||
PublishedSession session = AssertSuccess(await publisher.RegisterAsync(
|
||||
CreateRegistrationRequest("dispose-key"),
|
||||
"publisher-credential"));
|
||||
SessionLeaseMaintainer maintainer = publisher.CreateLeaseMaintainer(
|
||||
session,
|
||||
"publisher-credential");
|
||||
Task<LeaseMaintenanceResult> active = maintainer.RunAsync();
|
||||
await delay.Started.Task.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
|
||||
await maintainer.DisposeAsync();
|
||||
LeaseMaintenanceResult result = await active;
|
||||
|
||||
Assert.Equal(LeaseMaintenanceStopReason.Disposed, result.Reason);
|
||||
Assert.Single(handler.RequestUris);
|
||||
await Assert.ThrowsAsync<ObjectDisposedException>(() => maintainer.RunAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CallerCancellationStopsLeaseMaintenanceWithoutRenewing()
|
||||
{
|
||||
ScriptedHandler handler = new(
|
||||
Response(HttpStatusCode.Created, JsonContent(CreateRegistrationResponse())));
|
||||
using HttpClient httpClient = new(handler) { BaseAddress = new("http://rendezvous.test/") };
|
||||
BlockingDelay delay = new();
|
||||
RendezvousPublisherClient publisher = new(httpClient, delay: delay);
|
||||
PublishedSession session = AssertSuccess(await publisher.RegisterAsync(
|
||||
CreateRegistrationRequest("cancel-key"),
|
||||
"publisher-credential"));
|
||||
await using SessionLeaseMaintainer maintainer = publisher.CreateLeaseMaintainer(
|
||||
session,
|
||||
"publisher-credential");
|
||||
using CancellationTokenSource cancellation = new();
|
||||
Task<LeaseMaintenanceResult> active = maintainer.RunAsync(cancellation.Token);
|
||||
await delay.Started.Task.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
|
||||
await cancellation.CancelAsync();
|
||||
LeaseMaintenanceResult result = await active;
|
||||
|
||||
Assert.Equal(LeaseMaintenanceStopReason.Cancelled, result.Reason);
|
||||
Assert.Single(handler.RequestUris);
|
||||
}
|
||||
|
||||
private static T AssertSuccess<T>(RendezvousClientResult<T> result)
|
||||
{
|
||||
Assert.True(result.IsSuccess, result.Message);
|
||||
return Assert.IsType<T>(result.Value);
|
||||
}
|
||||
|
||||
private static RegisterSessionRequest CreateRegistrationRequest(string idempotencyKey) => new()
|
||||
{
|
||||
IdempotencyKey = idempotencyKey,
|
||||
GameId = new("space-game"),
|
||||
EnvironmentId = new("production"),
|
||||
RegionId = new("eu-central"),
|
||||
ProtocolVersion = 7,
|
||||
BuildVersion = "1.0.0",
|
||||
DisplayName = "SDK host",
|
||||
Visibility = ListingVisibility.Public,
|
||||
Capacity = new() { CurrentPlayers = 1, MaximumPlayers = 4 },
|
||||
};
|
||||
|
||||
private static RegisterSessionResponse CreateRegistrationResponse() => new()
|
||||
{
|
||||
ListingId = new(Guid.Parse("00000000-0000-0000-0000-000000000010")),
|
||||
LeaseId = new(Guid.Parse("00000000-0000-0000-0000-000000000011")),
|
||||
LeaseToken = "lease-token",
|
||||
HostPresenceHandle = new(Guid.Parse("00000000-0000-0000-0000-000000000012")),
|
||||
HostPresenceCapability = "presence-capability",
|
||||
ExpiresAt = new DateTimeOffset(2030, 1, 1, 0, 0, 0, TimeSpan.Zero),
|
||||
LeaseRenewAfterSeconds = 15,
|
||||
HostPresenceRefreshAfterSeconds = 10,
|
||||
};
|
||||
|
||||
private static SessionListing CreateListing(string id) => new()
|
||||
{
|
||||
ListingId = new(Guid.Parse(id)),
|
||||
GameId = new("space-game"),
|
||||
EnvironmentId = new("production"),
|
||||
RegionId = new("eu-central"),
|
||||
ProtocolVersion = 7,
|
||||
BuildVersion = "1.0.0",
|
||||
DisplayName = "host",
|
||||
Visibility = ListingVisibility.Public,
|
||||
PublisherTrustMode = PublisherTrustMode.ManagedDedicated,
|
||||
Capacity = new() { CurrentPlayers = 1, MaximumPlayers = 4 },
|
||||
};
|
||||
|
||||
private static TrackingContent JsonContent<T>(T value) => new(
|
||||
JsonSerializer.SerializeToUtf8Bytes(value, ContractJson.Options));
|
||||
|
||||
private static HttpResponseMessage Response(HttpStatusCode status, HttpContent content) => new(status)
|
||||
{
|
||||
Content = content,
|
||||
};
|
||||
|
||||
private sealed class ScriptedHandler(params HttpResponseMessage[] responses) : HttpMessageHandler
|
||||
{
|
||||
private readonly Queue<HttpResponseMessage> _responses = new(responses);
|
||||
|
||||
internal List<string> RequestBodies { get; } = [];
|
||||
internal List<Uri> RequestUris { get; } = [];
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
RequestUris.Add(request.RequestUri!);
|
||||
RequestBodies.Add(request.Content is null
|
||||
? string.Empty
|
||||
: await request.Content.ReadAsStringAsync(cancellationToken));
|
||||
return _responses.Count > 0
|
||||
? _responses.Dequeue()
|
||||
: throw new InvalidOperationException("No scripted response remains.");
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TrackingContent(byte[] bytes) : HttpContent
|
||||
{
|
||||
internal bool IsDisposed { get; private set; }
|
||||
|
||||
protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context) =>
|
||||
stream.WriteAsync(bytes).AsTask();
|
||||
|
||||
protected override bool TryComputeLength(out long length)
|
||||
{
|
||||
length = bytes.Length;
|
||||
return true;
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
IsDisposed = true;
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class RecordingDelay(Action? onDelay = null) : IRendezvousDelay
|
||||
{
|
||||
internal List<TimeSpan> Delays { get; } = [];
|
||||
|
||||
public Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
Delays.Add(delay);
|
||||
onDelay?.Invoke();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class BlockingDelay : IRendezvousDelay
|
||||
{
|
||||
internal TaskCompletionSource Started { get; } = new(
|
||||
TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
public Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken)
|
||||
{
|
||||
Started.TrySetResult();
|
||||
return Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
using FinalFactory.Rendezvous.Client;
|
||||
using FinalFactory.Rendezvous.Contracts;
|
||||
using FinalFactory.Rendezvous.Server.Browser;
|
||||
using FinalFactory.Rendezvous.Server.Http;
|
||||
using FinalFactory.Rendezvous.Server.Provisioning;
|
||||
using FinalFactory.Rendezvous.Server.Sessions;
|
||||
using FinalFactory.Rendezvous.Server.State;
|
||||
using FinalFactory.Rendezvous.Tests.Provisioning;
|
||||
using FinalFactory.Rendezvous.Tests.State;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Hosting.Server;
|
||||
using Microsoft.AspNetCore.Hosting.Server.Features;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace FinalFactory.Rendezvous.Tests.Client;
|
||||
|
||||
public sealed class RendezvousClientIntegrationTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task PublisherAndBrowserClientsCompleteTheRealSessionLifecycleAndPaging()
|
||||
{
|
||||
await using ClientTestHost host = await ClientTestHost.StartAsync();
|
||||
RendezvousPublisherClient publisher = new(host.HttpClient);
|
||||
RendezvousSessionBrowserClient browser = new(host.HttpClient);
|
||||
List<PublishedSession> sessions = [];
|
||||
|
||||
for (int index = 0; index < 3; index++)
|
||||
{
|
||||
RendezvousClientResult<PublishedSession> registered = await publisher.RegisterAsync(
|
||||
CreateRegistration(index),
|
||||
host.PublisherCredential);
|
||||
PublishedSession session = AssertSuccess(registered);
|
||||
sessions.Add(session);
|
||||
Assert.True(host.Capabilities.TryFingerprint(
|
||||
session.HostPresenceCapability,
|
||||
out SecretFingerprint fingerprint));
|
||||
StoreResult<StoredListing> bound = host.Store.BindHostPresence(new(
|
||||
session.HostPresenceHandle,
|
||||
fingerprint,
|
||||
new(AddressFamilyKind.Ipv4, $"203.0.113.{80 + index}", 41_000 + index),
|
||||
null));
|
||||
Assert.Equal(StoreResultCode.Success, bound.Code);
|
||||
}
|
||||
|
||||
PublishedSession first = sessions[0];
|
||||
RendezvousClientResult<RenewLeaseResponse> renewed = await publisher.RenewAsync(
|
||||
first,
|
||||
host.PublisherCredential);
|
||||
Assert.True(renewed.IsSuccess, renewed.Message);
|
||||
Assert.Equal(renewed.Value!.ExpiresAt, first.ExpiresAt);
|
||||
|
||||
UpdateSessionRequest update = new()
|
||||
{
|
||||
BuildVersion = "2.0.0",
|
||||
DisplayName = "SDK host updated",
|
||||
Capacity = new() { CurrentPlayers = 2, MaximumPlayers = 8 },
|
||||
Metadata = new() { ["mode"] = "online-coop" },
|
||||
};
|
||||
RendezvousClientResult<bool> updated = await publisher.UpdateAsync(
|
||||
first,
|
||||
update,
|
||||
host.PublisherCredential);
|
||||
Assert.True(updated.IsSuccess, updated.Message);
|
||||
|
||||
BrowseSessionsRequest browseRequest = new()
|
||||
{
|
||||
GameId = new("space-game"),
|
||||
EnvironmentId = new("production"),
|
||||
RegionId = new("eu-central"),
|
||||
ProtocolVersion = 7,
|
||||
PageSize = 1,
|
||||
ExcludeFull = true,
|
||||
};
|
||||
IReadOnlyList<SessionListing> listings = AssertSuccess(
|
||||
await browser.BrowseAllAsync(browseRequest));
|
||||
Assert.Equal(3, listings.Count);
|
||||
Assert.Equal("SDK host updated", listings.Single(item => item.ListingId == first.ListingId).DisplayName);
|
||||
|
||||
GetSessionResponse direct = AssertSuccess(await browser.GetAsync(
|
||||
first.ListingId,
|
||||
new("space-game"),
|
||||
new("production"),
|
||||
7));
|
||||
Assert.Equal("2.0.0", direct.Session.BuildVersion);
|
||||
|
||||
foreach (PublishedSession session in sessions)
|
||||
{
|
||||
RendezvousClientResult<bool> deregistered = await publisher.DeregisterAsync(
|
||||
session,
|
||||
host.PublisherCredential);
|
||||
Assert.True(deregistered.IsSuccess, deregistered.Message);
|
||||
Assert.Equal(StoreResultCode.NotFound, host.Store.GetListing(session.ListingId, false).Code);
|
||||
}
|
||||
}
|
||||
|
||||
private static T AssertSuccess<T>(RendezvousClientResult<T> result)
|
||||
{
|
||||
Assert.True(result.IsSuccess, result.Message);
|
||||
return Assert.IsAssignableFrom<T>(result.Value);
|
||||
}
|
||||
|
||||
private static RegisterSessionRequest CreateRegistration(int index) => new()
|
||||
{
|
||||
IdempotencyKey = $"sdk-integration-{index}",
|
||||
GameId = new("space-game"),
|
||||
EnvironmentId = new("production"),
|
||||
RegionId = new("eu-central"),
|
||||
ProtocolVersion = 7,
|
||||
BuildVersion = "1.0.0",
|
||||
DisplayName = $"SDK host {index}",
|
||||
Visibility = ListingVisibility.Public,
|
||||
Capacity = new() { CurrentPlayers = 1, MaximumPlayers = 8 },
|
||||
Metadata = new() { ["mode"] = "online-coop" },
|
||||
};
|
||||
|
||||
private sealed class ClientTestHost : IAsyncDisposable
|
||||
{
|
||||
private readonly WebApplication _application;
|
||||
|
||||
private ClientTestHost(
|
||||
WebApplication application,
|
||||
HttpClient httpClient,
|
||||
InMemoryEphemeralRendezvousStore store,
|
||||
EphemeralCapabilityIssuer capabilities,
|
||||
string publisherCredential)
|
||||
{
|
||||
_application = application;
|
||||
HttpClient = httpClient;
|
||||
Store = store;
|
||||
Capabilities = capabilities;
|
||||
PublisherCredential = publisherCredential;
|
||||
}
|
||||
|
||||
internal HttpClient HttpClient { get; }
|
||||
internal InMemoryEphemeralRendezvousStore Store { get; }
|
||||
internal EphemeralCapabilityIssuer Capabilities { get; }
|
||||
internal string PublisherCredential { get; }
|
||||
|
||||
internal static async Task<ClientTestHost> StartAsync()
|
||||
{
|
||||
ManualRendezvousClock clock = new(ProvisioningTestData.Now);
|
||||
EphemeralStoreOptions stateOptions = new();
|
||||
InMemoryEphemeralRendezvousStore store = new(stateOptions, clock, clock);
|
||||
EphemeralCapabilityIssuer capabilities = new();
|
||||
ProvisioningRuntime provisioning = ProvisioningRuntime.Create(
|
||||
ProvisioningTestData.CreateOptions(),
|
||||
ProvisioningTestData.CreateSecrets("secret-1"),
|
||||
clock.UtcNow);
|
||||
DedicatedPublisherPrincipal principal = ProvisioningTestData.CreateDedicatedPublisher();
|
||||
string credential = provisioning.Credentials.Issue(principal, clock.UtcNow);
|
||||
|
||||
WebApplicationBuilder builder = WebApplication.CreateBuilder();
|
||||
builder.WebHost.UseUrls("http://127.0.0.1:0");
|
||||
builder.Services.ConfigureHttpJsonOptions(static options =>
|
||||
ContractJson.Configure(options.SerializerOptions));
|
||||
builder.Services.Configure<RouteHandlerOptions>(static options =>
|
||||
options.ThrowOnBadRequest = true);
|
||||
builder.Services.AddProblemDetails();
|
||||
builder.Services.AddExceptionHandler<RendezvousExceptionHandler>();
|
||||
builder.Services.AddSingleton(provisioning);
|
||||
builder.Services.AddSingleton(provisioning.Credentials);
|
||||
builder.Services.AddSingleton(provisioning.PublisherAuthorization);
|
||||
builder.Services.AddSingleton<IEphemeralRendezvousStore>(store);
|
||||
builder.Services.AddSingleton<IWallClock>(clock);
|
||||
builder.Services.AddSingleton(capabilities);
|
||||
builder.Services.AddSingleton<ISessionCapabilityService>(capabilities);
|
||||
builder.Services.AddSingleton(SessionLeaseTiming.From(stateOptions));
|
||||
builder.Services.AddSingleton<SessionLeaseService>();
|
||||
builder.Services.AddSingleton<SessionBrowserCursorCodec>();
|
||||
builder.Services.AddSingleton<SessionBrowserService>();
|
||||
|
||||
WebApplication app = builder.Build();
|
||||
app.UseExceptionHandler();
|
||||
app.MapRendezvousContractEndpoints();
|
||||
await app.StartAsync();
|
||||
IServer server = app.Services.GetRequiredService<IServer>();
|
||||
string address = Assert.Single(server.Features.Get<IServerAddressesFeature>()!.Addresses);
|
||||
return new(
|
||||
app,
|
||||
new HttpClient { BaseAddress = new Uri(address) },
|
||||
store,
|
||||
capabilities,
|
||||
credential);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
HttpClient.Dispose();
|
||||
await _application.StopAsync();
|
||||
await _application.DisposeAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Reflection;
|
||||
using FinalFactory.Rendezvous.Client;
|
||||
using FinalFactory.Rendezvous.Contracts;
|
||||
|
||||
namespace FinalFactory.Rendezvous.Tests.Contracts;
|
||||
@@ -23,6 +24,24 @@ public sealed class PublicApiCompatibilityTests
|
||||
Assert.Equal(expected, snapshot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClientPublicApiMatchesTheV1Snapshot()
|
||||
{
|
||||
string snapshot = CreateSnapshot(typeof(RendezvousPublisherClient).Assembly);
|
||||
string expected = ContractTestFiles.Read("client-public-api.txt");
|
||||
if (expected == "SNAPSHOT_PENDING"
|
||||
&& Environment.GetEnvironmentVariable("RENDEZVOUS_UPDATE_CONTRACT_SNAPSHOT") == "1")
|
||||
{
|
||||
string snapshotPath = Path.Combine(
|
||||
ContractTestFiles.Directory,
|
||||
"client-public-api.txt");
|
||||
File.WriteAllText(snapshotPath, snapshot + Environment.NewLine);
|
||||
expected = snapshot;
|
||||
}
|
||||
|
||||
Assert.Equal(expected, snapshot);
|
||||
}
|
||||
|
||||
private static string CreateSnapshot(Assembly assembly)
|
||||
{
|
||||
List<string> lines = [];
|
||||
@@ -58,10 +77,16 @@ public sealed class PublicApiCompatibilityTests
|
||||
foreach (PropertyInfo property in type.GetProperties(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly)
|
||||
.OrderBy(static property => property.Name, StringComparer.Ordinal))
|
||||
{
|
||||
string accessors = $"{(property.CanRead ? "get;" : string.Empty)}{(property.CanWrite ? "set;" : string.Empty)}";
|
||||
string accessors = $"{(property.GetMethod?.IsPublic == true ? "get;" : string.Empty)}{(property.SetMethod?.IsPublic == true ? "set;" : string.Empty)}";
|
||||
lines.Add($" PROP {FormatType(property.PropertyType)} {property.Name} {{{accessors}}}");
|
||||
}
|
||||
|
||||
foreach (EventInfo eventInfo in type.GetEvents(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly)
|
||||
.OrderBy(static eventInfo => eventInfo.Name, StringComparer.Ordinal))
|
||||
{
|
||||
lines.Add($" EVENT {FormatType(eventInfo.EventHandlerType!)} {eventInfo.Name}");
|
||||
}
|
||||
|
||||
foreach (MethodInfo method in type.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly)
|
||||
.Where(static method => !method.IsSpecialName || method.Name.StartsWith("op_", StringComparison.Ordinal))
|
||||
.OrderBy(static method => method.Name, StringComparer.Ordinal)
|
||||
|
||||
@@ -4,6 +4,7 @@ using System.Net.Http.Json;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using FinalFactory.Rendezvous.Contracts;
|
||||
using FinalFactory.Rendezvous.Server.Browser;
|
||||
using FinalFactory.Rendezvous.Server.Http;
|
||||
using FinalFactory.Rendezvous.Server.Provisioning;
|
||||
using FinalFactory.Rendezvous.Server.Sessions;
|
||||
@@ -52,6 +53,8 @@ public sealed class SessionHttpEndpointTests
|
||||
builder.Services.AddSingleton<ISessionCapabilityService>(capabilities);
|
||||
builder.Services.AddSingleton(SessionLeaseTiming.From(stateOptions));
|
||||
builder.Services.AddSingleton<SessionLeaseService>();
|
||||
builder.Services.AddSingleton<SessionBrowserCursorCodec>();
|
||||
builder.Services.AddSingleton<SessionBrowserService>();
|
||||
await using WebApplication app = builder.Build();
|
||||
app.UseExceptionHandler();
|
||||
app.MapRendezvousContractEndpoints();
|
||||
@@ -107,6 +110,23 @@ public sealed class SessionHttpEndpointTests
|
||||
ContractJson.Options);
|
||||
Assert.NotNull(session);
|
||||
Assert.Equal($"/v1/sessions/{session.ListingId}", created.Headers.Location!.OriginalString);
|
||||
Assert.True(capabilities.TryFingerprint(
|
||||
session.HostPresenceCapability,
|
||||
out SecretFingerprint presenceFingerprint));
|
||||
store.BindHostPresence(new(
|
||||
session.HostPresenceHandle,
|
||||
presenceFingerprint,
|
||||
new(AddressFamilyKind.Ipv4, "203.0.113.80", 41_000),
|
||||
null));
|
||||
|
||||
BrowseSessionsResponse? browser = await client.GetFromJsonAsync<BrowseSessionsResponse>(
|
||||
"/v1/sessions?contractVersion=1&gameId=space-game&environmentId=production&protocolVersion=7®ionId=eu-central&pageSize=10&excludeFull=true",
|
||||
ContractJson.Options);
|
||||
Assert.Equal(session.ListingId, Assert.Single(browser!.Items).ListingId);
|
||||
GetSessionResponse? direct = await client.GetFromJsonAsync<GetSessionResponse>(
|
||||
$"/v1/sessions/{session.ListingId}?contractVersion=1&gameId=space-game&environmentId=production&protocolVersion=7",
|
||||
ContractJson.Options);
|
||||
Assert.Equal(session.ListingId, direct!.Session.ListingId);
|
||||
|
||||
HttpResponseMessage renewed = await client.PostAsJsonAsync(
|
||||
$"/v1/sessions/{session.ListingId}/renew",
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
TYPE FinalFactory.Rendezvous.Client.IRendezvousDelay
|
||||
METHOD System.Threading.Tasks.Task DelayAsync(System.TimeSpan delay, System.Threading.CancellationToken cancellationToken)
|
||||
TYPE FinalFactory.Rendezvous.Client.IRendezvousPublisherClient
|
||||
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<System.Boolean>> DeregisterAsync(FinalFactory.Rendezvous.Client.PublishedSession session, System.String publisherCredential, System.Threading.CancellationToken cancellationToken)
|
||||
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<FinalFactory.Rendezvous.Client.PublishedSession>> RegisterAsync(FinalFactory.Rendezvous.Contracts.RegisterSessionRequest request, System.String publisherCredential, System.Threading.CancellationToken cancellationToken)
|
||||
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<FinalFactory.Rendezvous.Contracts.RenewLeaseResponse>> RenewAsync(FinalFactory.Rendezvous.Client.PublishedSession session, System.String publisherCredential, System.Threading.CancellationToken cancellationToken)
|
||||
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<System.Boolean>> UpdateAsync(FinalFactory.Rendezvous.Client.PublishedSession session, FinalFactory.Rendezvous.Contracts.UpdateSessionRequest request, System.String publisherCredential, System.Threading.CancellationToken cancellationToken)
|
||||
TYPE FinalFactory.Rendezvous.Client.IRendezvousSessionBrowserClient
|
||||
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<System.Collections.Generic.IReadOnlyList<FinalFactory.Rendezvous.Contracts.SessionListing>>> BrowseAllAsync(FinalFactory.Rendezvous.Contracts.BrowseSessionsRequest request, System.Int32 maximumPages, System.Threading.CancellationToken cancellationToken)
|
||||
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<FinalFactory.Rendezvous.Contracts.BrowseSessionsResponse>> BrowseAsync(FinalFactory.Rendezvous.Contracts.BrowseSessionsRequest request, System.Threading.CancellationToken cancellationToken)
|
||||
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<FinalFactory.Rendezvous.Contracts.GetSessionResponse>> GetAsync(FinalFactory.Rendezvous.Contracts.SessionListingId listingId, FinalFactory.Rendezvous.Contracts.GameId gameId, FinalFactory.Rendezvous.Contracts.EnvironmentId environmentId, System.UInt32 protocolVersion, System.Threading.CancellationToken cancellationToken)
|
||||
TYPE FinalFactory.Rendezvous.Client.LeaseMaintenanceResult
|
||||
PROP FinalFactory.Rendezvous.Contracts.RendezvousErrorCode Error {get;}
|
||||
PROP FinalFactory.Rendezvous.Client.LeaseMaintenanceStopReason Reason {get;}
|
||||
TYPE FinalFactory.Rendezvous.Client.LeaseMaintenanceStopReason
|
||||
ENUM Cancelled=1
|
||||
ENUM Disposed=2
|
||||
ENUM LeaseLost=3
|
||||
ENUM Failed=4
|
||||
TYPE FinalFactory.Rendezvous.Client.PublishedSession
|
||||
PROP System.DateTimeOffset ExpiresAt {get;}
|
||||
PROP System.String HostPresenceCapability {get;}
|
||||
PROP FinalFactory.Rendezvous.Contracts.MediationHandle HostPresenceHandle {get;}
|
||||
PROP System.Int32 HostPresenceRefreshAfterSeconds {get;}
|
||||
PROP FinalFactory.Rendezvous.Contracts.LeaseId LeaseId {get;}
|
||||
PROP System.Int32 LeaseRenewAfterSeconds {get;}
|
||||
PROP System.String LeaseToken {get;}
|
||||
PROP FinalFactory.Rendezvous.Contracts.SessionListingId ListingId {get;}
|
||||
METHOD System.String ToString()
|
||||
TYPE FinalFactory.Rendezvous.Client.RendezvousClientOptions
|
||||
CTOR ()
|
||||
PROP System.TimeSpan InitialRetryDelay {get;set;}
|
||||
PROP System.Double JitterRatio {get;set;}
|
||||
PROP System.TimeSpan MaximumRetryDelay {get;set;}
|
||||
PROP System.Int32 MaximumSafeRetries {get;set;}
|
||||
TYPE FinalFactory.Rendezvous.Client.RendezvousClientResult
|
||||
METHOD FinalFactory.Rendezvous.Client.RendezvousClientResult<T> Failure(FinalFactory.Rendezvous.Contracts.RendezvousErrorCode error, System.String message, System.Nullable<System.Int32> retryAfterSeconds)
|
||||
METHOD FinalFactory.Rendezvous.Client.RendezvousClientResult<T> Success(T value)
|
||||
TYPE FinalFactory.Rendezvous.Client.RendezvousClientResult<T>
|
||||
PROP FinalFactory.Rendezvous.Contracts.RendezvousErrorCode Error {get;}
|
||||
PROP System.Boolean IsSuccess {get;}
|
||||
PROP System.String Message {get;}
|
||||
PROP System.Nullable<System.Int32> RetryAfterSeconds {get;}
|
||||
PROP T Value {get;}
|
||||
TYPE FinalFactory.Rendezvous.Client.RendezvousPublisherClient
|
||||
CTOR (System.Net.Http.HttpClient httpClient, FinalFactory.Rendezvous.Client.RendezvousClientOptions options, FinalFactory.Rendezvous.Client.IRendezvousDelay delay)
|
||||
METHOD FinalFactory.Rendezvous.Client.SessionLeaseMaintainer CreateLeaseMaintainer(FinalFactory.Rendezvous.Client.PublishedSession session, System.String publisherCredential)
|
||||
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<System.Boolean>> DeregisterAsync(FinalFactory.Rendezvous.Client.PublishedSession session, System.String publisherCredential, System.Threading.CancellationToken cancellationToken)
|
||||
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<FinalFactory.Rendezvous.Client.PublishedSession>> RegisterAsync(FinalFactory.Rendezvous.Contracts.RegisterSessionRequest request, System.String publisherCredential, System.Threading.CancellationToken cancellationToken)
|
||||
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<FinalFactory.Rendezvous.Contracts.RenewLeaseResponse>> RenewAsync(FinalFactory.Rendezvous.Client.PublishedSession session, System.String publisherCredential, System.Threading.CancellationToken cancellationToken)
|
||||
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<System.Boolean>> UpdateAsync(FinalFactory.Rendezvous.Client.PublishedSession session, FinalFactory.Rendezvous.Contracts.UpdateSessionRequest request, System.String publisherCredential, System.Threading.CancellationToken cancellationToken)
|
||||
TYPE FinalFactory.Rendezvous.Client.RendezvousSessionBrowserClient
|
||||
CTOR (System.Net.Http.HttpClient httpClient, FinalFactory.Rendezvous.Client.RendezvousClientOptions options, FinalFactory.Rendezvous.Client.IRendezvousDelay delay)
|
||||
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<System.Collections.Generic.IReadOnlyList<FinalFactory.Rendezvous.Contracts.SessionListing>>> BrowseAllAsync(FinalFactory.Rendezvous.Contracts.BrowseSessionsRequest request, System.Int32 maximumPages, System.Threading.CancellationToken cancellationToken)
|
||||
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<FinalFactory.Rendezvous.Contracts.BrowseSessionsResponse>> BrowseAsync(FinalFactory.Rendezvous.Contracts.BrowseSessionsRequest request, System.Threading.CancellationToken cancellationToken)
|
||||
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<FinalFactory.Rendezvous.Contracts.GetSessionResponse>> GetAsync(FinalFactory.Rendezvous.Contracts.SessionListingId listingId, FinalFactory.Rendezvous.Contracts.GameId gameId, FinalFactory.Rendezvous.Contracts.EnvironmentId environmentId, System.UInt32 protocolVersion, System.Threading.CancellationToken cancellationToken)
|
||||
TYPE FinalFactory.Rendezvous.Client.SessionLeaseMaintainer
|
||||
EVENT System.EventHandler LeaseLost
|
||||
METHOD System.Threading.Tasks.ValueTask DisposeAsync()
|
||||
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.LeaseMaintenanceResult> RunAsync(System.Threading.CancellationToken cancellationToken)
|
||||
@@ -18,6 +18,7 @@ TYPE FinalFactory.Rendezvous.Contracts.BrowseSessionsRequest
|
||||
PROP System.Int32 ContractVersion {get;set;}
|
||||
PROP System.String Cursor {get;set;}
|
||||
PROP FinalFactory.Rendezvous.Contracts.EnvironmentId EnvironmentId {get;set;}
|
||||
PROP System.Boolean ExcludeFull {get;set;}
|
||||
PROP FinalFactory.Rendezvous.Contracts.GameId GameId {get;set;}
|
||||
PROP System.Int32 PageSize {get;set;}
|
||||
PROP System.UInt32 ProtocolVersion {get;set;}
|
||||
|
||||
Reference in New Issue
Block a user