Compare commits

..

3 Commits

Author SHA1 Message Date
KyuubiYoru 69c8b2d2bc feat: freeze v1 transport contracts (#4)
quality-gate / quality (push) Successful in 51s
Closes #4
2026-07-16 04:52:38 +02:00
KyuubiYoru e626b89909 build: bootstrap solution and quality gates (#3)
quality-gate / quality (push) Successful in 58s
Closes #3
2026-07-16 04:25:54 +02:00
KyuubiYoru 286fbfeb36 docs: ratify v1 architecture and threat model (#2)
Closes #2
2026-07-16 04:11:24 +02:00
63 changed files with 5006 additions and 0 deletions
+26
View File
@@ -0,0 +1,26 @@
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
[*.{cs,csproj,props,targets}]
indent_style = space
indent_size = 4
[*.cs]
dotnet_sort_system_directives_first = true
csharp_new_line_before_open_brace = all
csharp_style_namespace_declarations = file_scoped:warning
csharp_style_var_for_built_in_types = false:suggestion
csharp_style_var_when_type_is_apparent = true:suggestion
csharp_style_var_elsewhere = false:suggestion
[*.{json,yml,yaml}]
indent_style = space
indent_size = 2
[*.md]
trim_trailing_whitespace = false
+37
View File
@@ -0,0 +1,37 @@
name: quality-gate
on:
push:
branches:
- main
- codex/**
pull_request:
workflow_dispatch:
jobs:
quality:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Install .NET SDK
uses: actions/setup-dotnet@v4
with:
dotnet-version: 10.0.301
- name: Restore locked dependencies
run: dotnet restore Rendezvous.slnx --locked-mode
- name: Verify formatting and analyzers
run: dotnet format Rendezvous.slnx --verify-no-changes --no-restore
- name: Build
run: dotnet build Rendezvous.slnx --configuration Release --no-restore
- name: Verify generated API contract
run: git diff --exit-code -- docs/api
- name: Test
run: dotnet test Rendezvous.slnx --configuration Release --no-build
+8
View File
@@ -0,0 +1,8 @@
**/bin/
**/obj/
TestResults/
.idea/
.vs/
*.suo
*.user
*.userosscache
+15
View File
@@ -0,0 +1,15 @@
<Project>
<PropertyGroup>
<AnalysisLevel>latest-recommended</AnalysisLevel>
<ContinuousIntegrationBuild Condition="'$(CI)' == 'true'">true</ContinuousIntegrationBuild>
<Deterministic>true</Deterministic>
<EnableNETAnalyzers>true</EnableNETAnalyzers>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
<RestoreLockedMode Condition="'$(CI)' == 'true'">true</RestoreLockedMode>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
</Project>
+16
View File
@@ -0,0 +1,16 @@
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<CentralPackageTransitivePinningEnabled>true</CentralPackageTransitivePinningEnabled>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="LiteNetLib" Version="2.1.4" />
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.9" />
<PackageVersion Include="Microsoft.Extensions.ApiDescription.Server" Version="10.0.9" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.4.0" />
<PackageVersion Include="Microsoft.OpenApi" Version="2.7.5" />
<PackageVersion Include="System.Text.Json" Version="10.0.10" />
<PackageVersion Include="xunit" Version="2.9.3" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />
</ItemGroup>
</Project>
+12
View File
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<clear />
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" protocolVersion="3" />
</packageSources>
<packageSourceMapping>
<packageSource key="nuget.org">
<package pattern="*" />
</packageSource>
</packageSourceMapping>
</configuration>
+23
View File
@@ -76,3 +76,26 @@ The initial service does not provide:
## Project status
Rendezvous is currently in its initial design and bootstrap stage. The first implementation should establish the contracts, directory leases, LiteNetLib mediator, client SDK, thin test client, and a three-party integration test before either game depends on it for production connectivity.
The ratified v1 boundaries, trust decisions, privacy rules, safety budgets, and
threat model are indexed in [the architecture documentation](docs/architecture/README.md).
The frozen v1 wire surface is documented in the
[HTTP, UDP, and generated OpenAPI contracts](docs/contracts/README.md).
## Development
The repository pins .NET SDK 10.0.301. From a clean clone, run the same gates as
CI from the repository root:
```bash
dotnet restore Rendezvous.slnx --locked-mode
dotnet format Rendezvous.slnx --verify-no-changes --no-restore
dotnet build Rendezvous.slnx --configuration Release --no-restore
dotnet test Rendezvous.slnx --configuration Release --no-build
```
Run the bootstrap server with
`dotnet run --project src/FinalFactory.Rendezvous.Server`. It serves HTTP health endpoints and binds
the configured UDP mediator port; both stop through normal host cancellation.
The project dependency rules and supported runtime choices are documented in
[project and dependency boundaries](docs/architecture/project-boundaries.md).
+11
View File
@@ -0,0 +1,11 @@
<Solution>
<Folder Name="/src/">
<Project Path="src/FinalFactory.Rendezvous.Client/FinalFactory.Rendezvous.Client.csproj" />
<Project Path="src/FinalFactory.Rendezvous.Contracts/FinalFactory.Rendezvous.Contracts.csproj" />
<Project Path="src/FinalFactory.Rendezvous.Server/FinalFactory.Rendezvous.Server.csproj" />
<Project Path="src/FinalFactory.Rendezvous.TestClient/FinalFactory.Rendezvous.TestClient.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/FinalFactory.Rendezvous.Tests/FinalFactory.Rendezvous.Tests.csproj" />
</Folder>
</Solution>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,113 @@
# ADR 0001: v1 control-plane boundaries and domain
- Status: Accepted
- Date: 2026-07-16
- Tracking: #2
## Context
Rendezvous must help two game peers discover and attempt an authenticated direct
connection without becoming a game server, an identity provider, or a gameplay
traffic service. The HTTP API and UDP mediator share short-lived state and must
agree on authorization, endpoint freshness, and tenant scope.
## Decision
V1 is one ASP.NET Core deployable with separable directory, join-authorization,
endpoint-registry, NAT-mediator, and operations modules. Modules communicate
through application interfaces, not through transport DTOs or one another's
storage implementation. Contracts and the client SDK remain independently
packageable.
The service is a connection control plane. A successful join authorization only
grants permission to attempt a direct connection. The game host remains the
final authority for player identity, capacity, bans, admission, and gameplay.
Rendezvous success is reported only after the host accepts a valid connection
ticket and LiteNetLib establishes the authenticated peer connection.
## Domain glossary
| Term | Definition | Lifetime and exposure |
| --- | --- | --- |
| `SessionListing` | Bounded public discovery data for one hosted game session. | Visible only while its lease and host presence are fresh. Never contains endpoints or credentials. |
| `Lease` | Renewable capability controlling the lifetime of a listing. | Secret, host-only, expires unless renewed. |
| `HostPresence` | Authenticated observation of the host's local and public UDP endpoints from its gameplay socket. | Internal, short-lived, never returned by browsing. |
| `JoinAttempt` | Authorization linking one client attempt to one compatible session. | Internal and short-lived; it is not authoritative game admission. |
| `PunchCapability` | Opaque, one-time credential scoped to attempt, role, tenant, and expiry. | Sent only to its intended peer; consumed at the UDP mediator. |
| `ConnectionTicket` | Compact signed credential presented to the host during the direct connection. | One-time, short-lived, and scoped to the attempt and host. |
IDs are opaque and tenant-scoped. They are never canonical player, entity, or
world identities.
## Trust boundaries
```mermaid
flowchart LR
Browser["Untrusted browser/client"] -->|"HTTPS: browse/join"| Proxy["Reverse proxy"]
Host["Game host"] -->|"HTTPS: register/renew"| Proxy
Operator["Privileged operator"] -->|"separate authenticated route"| Proxy
Proxy -->|"normalized HTTP + trusted forwarding metadata"| Service["Rendezvous service"]
Host -->|"host gameplay UDP socket"| Mediator["UDP mediator module"]
Browser -->|"client gameplay UDP socket"| Mediator
Mediator <--> Service
Service -->|"read keys; never list or log values"| Secrets["Secret provider"]
Service -.->|"future authenticated state protocol"| Store["Future shared store"]
Service -->|"redacted events and aggregate metrics"| Ops["Observability systems"]
```
- Public HTTP input is hostile even after TLS termination. The proxy may be
trusted to terminate TLS and supply forwarding metadata only when its source
address is allowlisted; forwarded headers from other sources are discarded.
- Public UDP input is hostile even when structurally valid. HTTP-supplied
endpoints are claims, never proof. Public response targets come only from an
authenticated UDP packet's observed source. A private local candidate may be
carried inside that packet only under ADR 0002's bounded same-LAN rules.
- Operator routes use a separate authentication policy and network exposure.
Operator access does not bypass tenant scoping, audit, or secret redaction.
- The client SDK is convenience code in an untrusted process. Server decisions
never rely on client-side validation or secrecy.
- Game hosts are authoritative only for their own gameplay admission. A host
cannot enumerate or mutate another game/environment tenant.
- The secret provider is trusted with long-lived key material. The application
receives only the minimum named key version it needs.
- A future shared store is a distinct authenticated boundary. Moving state to it
does not make stored input trusted and requires a new availability ADR.
## Connection data flow
```mermaid
sequenceDiagram
participant H as Game host
participant R as Rendezvous HTTP
participant M as Rendezvous UDP mediator
participant C as Game client
H->>R: Register listing (publisher authorization)
R-->>H: Lease + host-presence capability
H->>M: Presence from gameplay UDP socket
M->>R: Store observed endpoint and freshness
H->>R: Renew lease
C->>R: Browse compatible visible listings
C->>R: Request join attempt
R-->>C: Client punch capability
R-->>H: Host attempt/capability via authenticated poll or stream
H->>M: Host capability from gameplay UDP socket
C->>M: Client capability from gameplay UDP socket
M->>M: Validate scope, freshness, expiry, and replay state
M-->>H: Introduce verified client endpoints + connection ticket
M-->>C: Introduce verified host endpoints + connection ticket
C->>H: Direct LiteNetLib connect + ticket
H->>H: Validate and consume ticket; apply game admission
H-->>C: Authenticated peer connection or rejection
```
The mediator does not forward normal gameplay packets. A connection attempt
that times out or is rejected returns a typed outcome to the caller.
## Consequences
- Directory and mediator can ship together without erasing their module boundary.
- Contracts cannot expose server storage or LiteNetLib implementation types.
- Tests must cover the three-party host/service/client flow; an HTTP-only test is
insufficient evidence of a successful connection.
- Splitting modules into processes requires an explicit protocol, shared-state
ownership, deterministic mediator routing, and a superseding ADR.
@@ -0,0 +1,88 @@
# ADR 0002: publisher trust, discovery, compatibility, and fallback
- Status: Accepted
- Date: 2026-07-16
- Tracking: #2
## Context
Dedicated servers can protect provisioned credentials. Public game binaries
cannot. Discovery also needs rules that prevent accidental cross-game joins and
make the meaning of a successful authorization precise.
## Decision
### Publisher trust modes
Each `GameId` and `EnvironmentId` is provisioned policy, never caller-created
free text. V1 supports two visibly distinct publisher modes:
1. **Managed dedicated host.** A provisioned workload principal authenticates
with a rotatable credential held outside the game binary. It is scoped to
allowed games, environments, regions, and listing limits. Public or unlisted
discovery may be enabled by policy.
2. **Player-hosted session.** A short-lived publisher grant is minted by a
game-owned backend and is scoped to one game, environment, host, and expiry.
Rendezvous does not interpret it as player identity. If a game has no grant
issuer, it may opt into anonymous unlisted hosting with strict address and
concurrency limits; anonymous sessions can be joined only through an opaque
share code and never appear in public browsing.
A reusable credential embedded in a downloadable client is not authentication
and is rejected as a provisioning design. Responses and metrics expose the
publisher trust mode so operators and games can apply different policy without
claiming anonymous hosts are authenticated identities.
### Discovery and metadata
- `Public` listings can appear only in tenant-scoped compatible browsing.
- `Unlisted` listings never appear in browse results and require a random,
unguessable share code. Unlisted does not mean private; join authorization and
host admission still apply.
- Browser responses contain display data only. They exclude raw endpoints,
internal IDs, lease credentials, punch capabilities, and connection tickets.
- Metadata is treated as hostile data. It is schema/budget validated, stored and
returned as data, and never rendered as markup by the SDK or TestClient.
### Compatibility and address families
- `NetworkProtocolVersion` must match exactly in v1. `BuildVersion` is bounded
display/diagnostic text and never overrides protocol compatibility.
- `GameId` and `EnvironmentId` must match exactly. Region is a browse filter and
preference, not a compatibility escape hatch.
- IPv4 direct connection and NAT punching are required for v1.
- Contracts carry an address-family discriminator. IPv6 direct connections may
use observed global IPv6 endpoints when both peers support them, but IPv6 NAT
traversal is not a v1 release requirement.
- Public candidates are derived only from the authenticated UDP packet's source.
For same-LAN attempts, that packet may additionally claim at most one private
unicast candidate per supported address family. A local claim is scoped to the
capability and is introduced only to the opposite role in the same authorized
attempt after both roles contribute. Loopback, link-local, multicast,
unspecified, documentation, and otherwise invalid destinations are rejected.
The SDK bounds probes per introduced candidate and lets callers disable local
candidates. HTTP-supplied endpoint claims are never introduced.
### Authorization and fallback
Join authorization means only that Rendezvous permits a scoped connection
attempt. It does not reserve a game slot and does not authenticate a player to
the game. The host validates and consumes the connection ticket, then applies
its own capacity, ban, identity, and gameplay rules.
The SDK returns a typed outcome including success, cancellation, timeout,
incompatibility, stale host, service rejection, host rejection, and transport
failure. A game may provision an optional dedicated fallback endpoint. The SDK
reports it but never connects without an explicit caller decision.
Gameplay relay is not part of v1. It remains a separate future service whose
need is evaluated from privacy-safe measured direct-connection failures.
## Consequences
- A player-hosted game needs a game-owned grant issuer for public discovery.
- Anonymous player hosting is useful for direct invitations but makes no user
identity claim and receives the strictest quotas.
- Games remain responsible for presenting unsafe user-authored text safely.
- The exact-match v1 rule favors predictable interoperation over flexible
version ranges; a later compatibility scheme must be versioned explicitly.
@@ -0,0 +1,137 @@
# ADR 0003: state, privacy, availability, and safety budgets
- Status: Accepted
- Date: 2026-07-16
- Tracking: #2
## Context
V1 needs safe defaults before contracts and stores make them difficult to
change. The initial deployment is deliberately single-active and in-memory, so
its restart and availability behavior must be honest.
## Decision
### State and lifecycle
All directory, lease, presence, attempt, capability, ticket-consumption, and
rate-limit state is ephemeral and held behind atomic store interfaces. V1 has
one active writer/service instance. A second instance may be a cold standby but
must not accept public traffic concurrently.
```mermaid
stateDiagram-v2
[*] --> Registered: authenticated register
Registered --> Visible: fresh lease and fresh UDP presence
Visible --> Registered: presence becomes stale
Visible --> Visible: lease renew + presence refresh
Registered --> Expired: lease expires
Visible --> Expired: lease expires
Registered --> Revoked: host or operator revokes
Visible --> Revoked: host or operator revokes
Expired --> [*]
Revoked --> [*]
```
Restart loses all ephemeral state, used capabilities, and listings. Readiness is
false until HTTP, UDP, policy, key material, and the state store are ready. SDK
publishers use jittered backoff and re-register after a restart; old credentials
remain invalid. The service drains by refusing new registrations/attempts,
allowing a bounded completion window, then cancelling remaining work.
No horizontal scale is supported until shared atomic state and deterministic
mediator routing exist. A shared-state design is triggered when any of these is
true:
- one measured supported node cannot sustain 150% of the 30-day peak load;
- the approved availability target exceeds what single-active operation can meet;
- planned maintenance without listing loss becomes a product requirement; or
- a region needs more than one active mediator endpoint.
Relay remains independently triggered only when a representative real-network
canary shows direct-connect failure high enough to justify its privacy, abuse,
bandwidth, and operating cost.
### Initial time and size budgets
These are enforceable v1 ceilings, not suggestions. Contract issue #4 may lower
them but must not raise them without security review.
| Budget | V1 ceiling |
| --- | --- |
| HTTP request body | 16 KiB after content decoding; compressed request bodies are rejected in v1 |
| Listing metadata | 4 KiB encoded JSON, at most 32 keys; key 64 UTF-8 bytes; scalar value 256 UTF-8 bytes; nesting depth 3 |
| Browser page | 100 listings and 256 KiB encoded response; opaque cursor; stable bounded sort |
| UDP datagram accepted | 1,200 bytes; oversized or fragmented application payloads are dropped without response |
| Opaque HTTP credential | 1,024 bytes encoded |
| UDP capability or ticket | 768 bytes encoded, with the complete datagram still at most 1,200 bytes |
| Clock skew | 30 seconds maximum when validating issued/not-before/expiry times |
| Lease lifetime | 60 seconds; renewal accepted from 30 seconds; no client-selected extension |
| Host presence freshness | 20 seconds |
| Join attempt lifetime | 30 seconds |
| Punch capability lifetime | 30 seconds and one successful use per role |
| Connection ticket lifetime | 20 seconds and one successful host consumption |
| Graceful drain | 30 seconds maximum |
All work queues are bounded. Initial per-instance ceilings are 1,024 concurrent
HTTP requests, 4,096 queued UDP datagrams, and 10,000 active join attempts.
Overflow is rejected or dropped early with a metric; it never creates an
unbounded task, allocation, log entry, or retry loop.
For an endpoint that has not proved possession of a valid capability, the UDP
mediator sends no response. Once both valid peer contributions exist,
authenticated mediation sends at most one introduction datagram to each peer.
The combined response bytes caused by the completing contribution must be no
more than twice that contribution's bytes, giving zero unverified amplification
and at most 2.0 verified byte amplification. Protocol padding or a smaller
response enforces the byte ratio. Responses are sent only to endpoints observed
from the corresponding authenticated gameplay socket, never to an arbitrary
HTTP-supplied address.
### Supported and capacity profiles
The development profile is functional, not a production capacity claim. The
initial production candidate is one Linux instance with 2 vCPU and 2 GiB RAM,
targeting 25,000 visible listings, 10,000 active attempts, 200 HTTP requests per
second, and 2,000 UDP datagrams per second while staying below 70% sustained CPU
and 75% memory. Issue #18 must measure and publish the actual supported profile;
production is blocked if the target is not met or the documented profile is not
reduced accordingly.
The initial single-active service objective, after the real-network canary, is
99.5% monthly successful availability for valid in-profile requests, excluding
announced maintenance. In-profile latency objectives are p95 <= 200 ms for HTTP
and p95 <= 100 ms from the second valid UDP contribution to both introduction
datagrams. These are service objectives, not guarantees of NAT traversal.
### Data classification and retention
| Data | Classification | Retention and handling |
| --- | --- | --- |
| Raw public/local endpoints | Sensitive network data | In memory only while the lease/attempt requires it, then deleted within 10 minutes; never logged or exported as metric labels |
| Listing display metadata | Public-untrusted or unlisted-untrusted | In memory for the active lease; audit stores only schema/result and a listing ID, not metadata values |
| Lease/capability/ticket/key material | Secret | Opaque random credentials are retained only as keyed digests; signed credentials retain verification keys and consumption IDs, not issued plaintext; plaintext is returned only at creation and is never logged or traced |
| Principal and tenant IDs | Internal identifiers | Audit retention 30 days; access-controlled and never used as high-cardinality metric labels |
| Security/audit event | Confidential operations data | 30 days online, access-controlled; contains action, coarse result, tenant, principal, and correlation ID, but no raw endpoint or secret |
| Diagnostic attempt record | Sensitive diagnostic data | Disabled by default; when explicitly enabled, redacted record retained at most 24 hours; raw endpoints remain excluded |
| Aggregate outcome/capacity metrics | Operational aggregate | 13 months; only bounded dimensions such as game, environment, region, trust mode, and typed outcome |
Logs use allowlisted fields rather than after-the-fact redaction. Correlation IDs
are random and are not credentials. Error responses are stable and do not reveal
whether a cross-tenant resource exists.
## Owner decisions required before production
Implementation can proceed with the baseline above. Production remains blocked
until the owner records:
- the actual secret-provider and key-custody system for each environment;
- which games may enable anonymous unlisted player hosting;
- deployment regions, data-processing jurisdiction, and approval of the stated
30-day audit/13-month aggregate retention periods;
- the per-game dedicated fallback endpoint policy;
- the measured supported profile and whether the 99.5% single-active objective
is sufficient or shared-state/high-availability work must be brought forward.
These are configuration and launch decisions, not permission to weaken the
tenant, replay, endpoint-verification, or secret-handling controls.
+15
View File
@@ -0,0 +1,15 @@
# Rendezvous architecture decisions
These records define the v1 architecture baseline. A later change to a ratified
decision requires a superseding ADR and corresponding contract/test updates.
- [ADR 0001: v1 control-plane boundaries and domain](0001-v1-control-plane-boundaries.md)
- [ADR 0002: publisher trust, discovery, compatibility, and fallback](0002-publisher-trust-and-connection-policy.md)
- [ADR 0003: state, privacy, availability, and safety budgets](0003-state-privacy-availability-and-budgets.md)
- [Threat model](../security/threat-model.md)
- [Security promise and test matrix](../security/control-matrix.md)
- [Versioned HTTP and UDP contracts](../contracts/README.md)
These decisions intentionally leave gameplay authority, player identity,
simulation, persistence, social features, skill matchmaking, and gameplay
traffic with each game. Relay is future evidence-driven scope, not part of v1.
+51
View File
@@ -0,0 +1,51 @@
# Project and dependency boundaries
Tracking: #3
```text
FinalFactory.Rendezvous.Contracts <- FinalFactory.Rendezvous.Client
^ ^
| |
FinalFactory.Rendezvous.Server FinalFactory.Rendezvous.TestClient
```
- `Contracts` targets `netstandard2.1` and contains only versioned,
transport-neutral IDs and wire contracts. It cannot reference Server,
LiteNetLib, or Godot. Its only package is `System.Text.Json`, used for the
canonical cross-runtime JSON contract.
- `Client` targets `netstandard2.1`, references Contracts and the pinned
LiteNetLib package, and contains no Godot or Server dependency.
- `Server` targets .NET 10 LTS, references Contracts, LiteNetLib, and the
first-party ASP.NET Core OpenAPI generator, and owns HTTP hosting, UDP
mediation, application policy, and ephemeral state.
- `TestClient` targets .NET 8, references only the public Client/Contracts seams
and LiteNetLib, and must never reach into Server internals.
- `Tests` target .NET 10 and may reference every project solely to verify public
behavior and architecture boundaries.
The dependency-boundary tests inspect compiled assembly references. A forbidden
engine, transport, or server dependency therefore fails the normal test gate.
## Supported toolchain
- Build SDK: .NET SDK 10.0.301, pinned by `global.json`.
- Server runtime: .NET 10 LTS.
- Client/contracts compatibility target: .NET Standard 2.1, consumable by the
.NET 8-or-later runtime used by current Godot 4 C# projects.
- TestClient runtime: .NET 8.
- LiteNetLib: 2.1.4, pinned centrally and restored from the lock files.
- Microsoft.OpenApi: patched 2.7.5 line, centrally pinned because the version
originally pulled by the .NET 10 generator is affected by CVE-2026-49451.
The repository uses central package versions, per-project lock files,
deterministic compilation, nullable reference types, warnings as errors, current
.NET analyzers, and formatting verification. CI restores in locked mode so a
package graph change must be deliberate and committed.
Primary compatibility references:
- [.NET support policy](https://dotnet.microsoft.com/en-us/platform/support/policy)
- [Godot stable C# prerequisites](https://docs.godotengine.org/en/stable/tutorials/scripting/c_sharp/c_sharp_basics.html)
- [LiteNetLib 2.1.4 on NuGet](https://www.nuget.org/packages/LiteNetLib/2.1.4)
- [ASP.NET Core OpenAPI generation](https://learn.microsoft.com/en-us/aspnet/core/fundamentals/openapi/overview?view=aspnetcore-10.0)
- [Microsoft.OpenApi security advisory](https://github.com/advisories/GHSA-v5pm-xwqc-g5wc)
+18
View File
@@ -0,0 +1,18 @@
# Versioned contracts
Tracking: #4
The v1 contract is defined by three artifacts that are reviewed and versioned
together:
- [HTTP v1 semantics](http-v1.md)
- [UDP v1 wire format](udp-v1.md)
- [Generated OpenAPI 3.1 document](../api/rendezvous-v1.json)
The public .NET types live in `FinalFactory.Rendezvous.Contracts`, target
`netstandard2.1`, and contain no Server, Godot, or LiteNetLib dependency. Golden
vectors and a public-API snapshot make accidental wire or source compatibility
changes fail the normal test gate.
Any incompatible change requires a new contract version. Additive JSON fields
may be introduced within v1 because v1 readers ignore unknown object members.
+105
View File
@@ -0,0 +1,105 @@
# HTTP contract v1
Tracking: #4
All production endpoints require HTTPS. JSON uses UTF-8, camel-case property
names, compact output, string-valued camel-case enums, and ISO 8601 timestamps.
Every request that contains a body carries `contractVersion: 1`; browse carries
the same value as a required query parameter.
## Compatibility and parsing
- Contract version matching is exact. Any value other than `1` fails with
`unsupportedContractVersion`; it is never guessed or downgraded.
- Gameplay protocol matching is exact. `buildVersion` is display and diagnostic
text only and never decides compatibility.
- Unknown JSON object properties are ignored so additive v1 responses remain
readable. Unknown enum names, numeric enum values, comments, trailing commas,
invalid identifier strings, and excessive nesting are rejected.
- Game, environment, and region IDs are lowercase URL-safe slugs. Listing,
lease, join-attempt, and mediation IDs are non-empty UUIDs serialized as JSON
strings.
- Clients must honor request cancellation. A disconnected or cancelled request
does not promise a response body; the server should stop work where safe.
## Endpoints
| Method | Path | Purpose |
| --- | --- | --- |
| `POST` | `/v1/sessions` | Register a session and create its renewable lease. |
| `POST` | `/v1/sessions/{listingId}/renew` | Renew the listing lease. |
| `PUT` | `/v1/sessions/{listingId}` | Replace mutable browser fields and capacity. |
| `DELETE` | `/v1/sessions/{listingId}` | Withdraw a listing. |
| `GET` | `/v1/sessions` | Browse compatible public sessions. |
| `GET` | `/v1/sessions/{listingId}` | Resolve a public or explicitly shared unlisted listing. |
| `POST` | `/v1/join-attempts` | Authorize and create a short-lived join attempt. |
| `GET` | `/v1/sessions/{listingId}/join-attempts` | Let an authenticated host poll pending attempts. |
| `POST` | `/v1/join-attempts/{attemptId}/outcome` | Report a bounded connection outcome. |
| `GET` | `/health/live` | Report that the HTTP process is alive. |
| `GET` | `/health/ready` | Report whether the UDP mediator is bound and ready. |
The generated [OpenAPI document](../api/rendezvous-v1.json) is the normative
shape reference for parameters, bodies, and responses. Contract-only endpoints
return `501` until their behavior is implemented by the subsequent directory,
lease, and join-orchestration issues.
Host polling sends its reusable lease credential in
`X-Rendezvous-Lease-Token`; it must never be placed in a URL. Lease credentials
for mutation operations are carried in their request bodies. Public browser
responses contain no IP endpoints, lease tokens, punch capabilities, connection
tickets, player identifiers, or gameplay state.
## Idempotency, cursors, and retries
Registration and join creation require a caller-generated visible-ASCII
`idempotencyKey`. A repeat in the same authorization scope returns the original
result while the key is retained; reusing a key with a different payload fails
with `conflict`. Keys are opaque and must not contain credentials.
Cursors are opaque, endpoint-specific, short-lived values. A client may echo a
cursor only to the endpoint and filters that produced it. Invalid or expired
cursors fail with `invalidRequest`; clients restart browsing from the first page.
Renew, update, delete, and outcome reporting are safe to retry with the same
lease/attempt identity after a transport-level failure.
## Limits
Limits are measured after UTF-8 encoding where stated. Servers reject the
entire request rather than truncate values.
| Item | v1 limit |
| --- | ---: |
| HTTP request body | 16 KiB |
| Browser response body | 256 KiB |
| Browser page | 100 listings |
| Metadata document | 4 KiB, 32 keys |
| Metadata key / value | 64 / 256 UTF-8 bytes |
| Game / environment / region ID | 64 / 32 / 32 characters |
| Display name / build version | 128 / 64 UTF-8 bytes |
| Idempotency key | 64 visible ASCII characters |
| Cursor | 512 visible ASCII characters |
| Diagnostic code | 64 visible ASCII characters |
| Error message | 256 UTF-8 bytes |
| Reusable HTTP credential | 1,024 characters |
| Session capacity | 110,000 players |
## Error mapping
Errors use `ApiError` with a stable `code`, bounded safe `message`, optional
`correlationId`, and optional `retryAfterSeconds`. Messages are diagnostic and
must not be parsed. Secrets and raw credentials are never echoed.
| HTTP | Codes |
| ---: | --- |
| 400 | `invalidRequest`, `unsupportedContractVersion` |
| 401 | `authenticationRequired` |
| 403 | `forbidden` |
| 404 | `notFound` |
| 409 | `conflict`, `incompatibleProtocol`, `replayRejected`, `capacityExceeded` |
| 410 | `expired`, `staleHost` |
| 429 | `rateLimited` (with retry guidance when known) |
| 503 | `serviceUnavailable` (with retry guidance when known) |
| 500 | `internalError` |
Malformed input must receive the same bounded error family regardless of which
parser or validation stage rejected it.
+53
View File
@@ -0,0 +1,53 @@
# UDP presence contract v1
Tracking: #4
The UDP mediator accepts a single bounded presence envelope from a host or
client. It associates the authenticated mediation handle with the packet's
observed public source endpoint and the sender's reported local endpoint. It
does not carry gameplay packets.
All multi-byte integers use network byte order. UUID bytes use the canonical
RFC 4122 textual order (the byte pairs from the 32 hexadecimal digits), not the
mixed-endian layout returned by `Guid.ToByteArray()`.
## Datagram layout
| Offset | Size | Field |
| ---: | ---: | --- |
| 0 | 2 | Magic bytes `52 56` (`RV`). |
| 2 | 1 | Contract version, exactly `01`. |
| 3 | 1 | Message type: host presence `01`, client presence `02`. |
| 4 | 1 | Flags, exactly `00` in v1. |
| 5 | 16 | Non-empty mediation-handle UUID. |
| 21 | 1 | Address family: IPv4 `04`, IPv6 `06`. |
| 22 | 1 | Address length: `04` for IPv4, `10` for IPv6. |
| 23 | 4 or 16 | Raw local IP address bytes. |
| next | 2 | Local UDP port, 165535. |
| next | 1 | Capability length, 1192. |
| next | variable | ASCII base64url capability, without padding. |
No trailing bytes are permitted. The whole datagram is limited to 1,200 bytes,
well below common Internet path MTUs. The v1 capability limit is 192 characters,
which also keeps any value passed through LiteNetLib's 256-character NAT token
surface safely below that library boundary.
## Validation and failure behavior
Decoders return one stable failure category: oversized, truncated, invalid
magic, unsupported version, unknown message type, non-zero flags, invalid
handle, invalid address family, invalid address, invalid port, invalid
capability, or trailing data. Unknown versions and message types are rejected;
they are never interpreted as v1.
The address-family byte, encoded address length, and parsed address must agree.
The service derives the public endpoint from the UDP packet source and never
trusts a client-supplied public address. Reported local endpoints are candidates
only and grant no authority.
Capabilities are short-lived, single-purpose, scoped to one mediation handle,
and compared without exposing them in logs. A valid-looking packet does not
prove authorization until the capability is checked. Invalid packets receive
no UDP response, preventing the mediator from becoming an amplification oracle.
Replay, expiry, pairing, and rate-limit policy are defined by later mediator
issues; the v1 envelope deliberately leaves no unbounded or reflected payload.
+27
View File
@@ -0,0 +1,27 @@
# README security promise and test matrix
Tracking: #2
This matrix turns each security and lifecycle promise in the README into an
enforceable control and planned evidence. Issue numbers refer to the delivery
backlog where the control is implemented and verified.
| README promise | Enforceable control | Planned evidence |
| --- | --- | --- |
| Per-game credentials and signing keys | Provisioned principals and versioned keys are scoped to game/environment; secrets come from a provider and never a public binary. (#5) | Cross-tenant authorization tests, rotation/overlap/revocation tests, and secret scans. |
| Short-lived, single-purpose tokens resistant to replay | Issuer fixes audience, tenant, attempt, role, issued/expiry times, nonce, and key ID; store atomically consumes nonce/ticket. (#4, #6, #10) | Golden vectors; expired, future, mutated, wrong-role, wrong-tenant, and concurrent replay tests. |
| Strict payload, metadata, and token size limits | ADR 0003 ceilings are checked before allocation/deserialization and again at domain construction. (#4, #15) | Boundary/property tests, malformed corpus, and allocation-aware fuzzing. |
| Registration, query, and introduction rate limits | Layered per-address, principal, tenant, and global token buckets with bounded queues and stable retry guidance. (#15) | Limit partition/isolation tests and overload/soak profiles. |
| Lease expiry removes abandoned servers | Visibility and join eligibility atomically require a fresh lease and fresh authenticated presence. (#6, #7) | Fake-clock expiry, renew/expire race, restart, and stale-host join tests. |
| Validate game, environment, room, and protocol boundaries | Every identifier is a validated type; store keys and authorization decisions include server-derived tenant scope; protocol is exact-match in v1. (#4-#10) | Contract, tenant-isolation, incompatible-version, and confused-deputy tests. |
| Structured audit events without secrets or reusable credentials | Allowlisted audit schema excludes metadata values, raw endpoints, tokens, and key material; event volume is bounded. (#16) | Captured-log/audit assertions and credential canary scans. |
| Public endpoint observation | Only authenticated UDP packets from the gameplay socket establish public endpoint ownership; bounded private local candidates follow ADR 0002 and HTTP claims are never introduced. (#11) | Spoofed-source, arbitrary-target, private-range, and same-LAN/external tests. |
| Authenticated join and punch tokens | Join issuance rechecks compatible visible listing; mediator validates scoped one-time capabilities; host consumes signed ticket. (#10-#12) | Deterministic three-party success, rejection, replay, mismatch, and timeout tests. |
| Clear timeouts and failure results | SDK owns explicit deadlines/cancellation and returns a closed typed outcome set; NAT introduction alone is not success. (#12, #13) | Fake-clock deadline/cancellation and host-rejection tests. |
| Isolation by game, environment, protocol, and region | Tenant and protocol are mandatory exact filters; region is bounded policy/filter data and cannot override tenant compatibility. (#5, #8, #15) | Cross-product browse/register/join isolation tests. |
| Operational health, metrics, logging, administration, and rate limiting | Separate liveness/readiness, bounded privacy-safe metrics/logs, authenticated operator controls, and overload signals. (#15, #16, #27) | Authorization matrix, redaction tests, dashboard queries, and failure-injection checks. |
| Service leaves gameplay path after direct connection | Mediator handles only presence/capability/introduction messages and has no gameplay forwarding API. (#4, #11) | Contract/API review, UDP unknown-message drop tests, and end-to-end traffic-path assertion. |
| Direct traversal is not guaranteed and requires fallback | SDK distinguishes traversal failure from service/host rejection and only returns configured fallback data for caller choice. Relay is absent from v1. (#13, #20, #24) | Typed-outcome tests and TestClient scripted fallback scenarios. |
Release readiness requires the linked implementation tests to exist and pass;
the design documents alone do not satisfy the security promise.
+96
View File
@@ -0,0 +1,96 @@
# Rendezvous v1 threat model
Tracking: #2
## Scope and assets
This model covers the public HTTP API, public LiteNetLib-compatible UDP mediator,
operator API, client SDK, game host integration, reverse proxy, secret provider,
observability pipeline, and the proposed future shared store. Gameplay traffic
after direct connection and game-owned identity/admission systems are outside
the service boundary, but their handoff is in scope.
Assets include tenant isolation, service availability, signing and publisher
keys, lease and connection credentials, raw endpoints, unlisted share codes,
listing integrity, audit integrity, and the guarantee that Rendezvous does not
turn into a reflector or private-network probe.
## Actors and assumptions
- Anonymous Internet attackers can send arbitrary HTTP and UDP traffic, spoof
source addresses where their network permits it, scrape listings, and create
many identities or addresses.
- Malicious publishers possess credentials only for their assigned tenant and
may submit hostile metadata or attempt to target arbitrary endpoints.
- Malicious clients can obtain legitimate join credentials for sessions they can
see and may replay, race, mutate, or share those credentials.
- A compromised game client and its SDK are fully attacker-controlled. No
reusable secret in them is trustworthy.
- Operators are privileged but fallible. Their actions are authenticated,
constrained, and audited.
- The reverse proxy, secret provider, and build/release pipeline are trusted
dependencies. Their compromise is considered and mitigated but cannot be
completely contained by the application.
## Abuse paths and controls
```mermaid
flowchart TD
A["Attacker input"] --> H{"HTTP or UDP?"}
H -->|HTTP| V["Authenticate when required; validate tenant, schema, size, and rate"]
H -->|UDP| U["Parse bounded datagram; validate capability before response"]
V --> S{"Allowed and in quota?"}
U --> E{"Capability valid, fresh, scoped, unused, and endpoint observed?"}
S -->|No| R["Stable bounded rejection"]
E -->|No| D["Silent drop + bounded aggregate metric"]
S -->|Yes| State["Atomic ephemeral state transition"]
E -->|Yes| State
State --> O["Allowlisted audit event; no secrets/endpoints"]
```
| Threat | Example | Required prevention/detection | Planned evidence |
| --- | --- | --- | --- |
| Spoofing and reflection | Forged UDP source causes traffic to a victim | No response before valid capability proof; send responses only to observed authenticated sources; at most two responses and <=2.0 verified byte amplification | Packet-level spoof/reflection tests and amplification accounting |
| Private-network probing | Publisher supplies `127.0.0.1`, link-local, or another victim as a same-LAN candidate | Accept only bounded private-unicast claims inside a scoped authenticated UDP contribution; reject prohibited ranges; disclose only to the opposite role in that attempt; bound SDK probes | Endpoint classification matrix and three-party adverse tests |
| Capability/ticket replay | Reuse a captured token to repeat introductions or connect | Short expiry, role/tenant/attempt scope, atomic one-time consumption, bounded skew, key rotation | Concurrent replay and post-expiry tests with golden vectors |
| Cross-tenant access | Game A browses, renews, or joins Game B | Server-derived principal scope on every lookup and atomic mutation; indistinguishable not-found response | Tenant isolation tests across every endpoint/store operation |
| Listing spam and scraping | Flood registrations or enumerate public sessions | Trust-mode quotas, per-principal/address limits, bounded pages/cursors, rate limits, aggregate alerts | Rate-limit, cursor-tamper, and sustained-load tests |
| Metadata injection | Control characters or markup attack logs/UI | UTF-8/schema/size validation; store as data; exclude values from audit; SDK does not render markup | Malformed Unicode/JSON corpus and TestClient safe-display tests |
| Credential theft | Secret appears in log, URL, metric, crash, or package | Credentials in headers/bodies only; allowlisted logging; secret-provider indirection; no credential metric labels | Log-capture tests, repository/package scans, rotation exercise |
| Parser/resource exhaustion | Oversized, nested, fragmented, or high-rate input | Fixed ceilings, bounded parsers/queues/concurrency, early rejection/drop, no input-sized logging | Fuzz/property corpus, allocation limits, overload tests |
| Stale or crashed host | Dead listing remains joinable | Both lease and recent authenticated presence required; atomic expiry; join rechecks freshness | Fake-clock lifecycle and join-race tests |
| Clock manipulation | Token accepted outside intended lifetime | Server-issued timestamps, monotonic elapsed-time for local expiry, <=30 s wall-clock skew | Boundary and clock-jump tests |
| Operator misuse | Unauthorized enumeration/revocation or secret exposure | Separate strong auth/network policy, least privilege, tenant scope, immutable audit, secrets never readable through API | Authorization matrix and audit completeness tests |
| Reverse-proxy confusion | Forged forwarded address bypasses limits | Trust forwarding headers only from allowlisted proxies; direct traffic uses socket peer | Forwarded-header spoof tests |
| Store race | Renew/revoke/expire/replay operations interleave | Compare-and-swap/transactional interfaces and deterministic outcomes | Parallel race tests with a fake clock |
| Dependency/supply-chain compromise | Malicious or drifting package/build output | Central pinning, lock files, reproducible builds, vulnerability review, signed release provenance | Locked clean restore, dependency audit, artifact verification |
| Availability attack | Valid-looking traffic fills CPU, memory, queues, logs | Layered quotas, bounded queues/tasks, graceful overload, readiness/drain, capacity alerts | Load/soak/resilience gates and forced saturation tests |
## Security invariants
The implementation and its tests must preserve these invariants:
1. No UDP response is sent to an endpoint that has not presented a valid scoped
capability from that observed endpoint.
2. No browse response contains an endpoint, secret, internal attempt ID, or
credential.
3. Every state lookup and mutation includes server-derived game/environment
scope; caller-supplied scope alone is never authoritative.
4. A listing is visible and joinable only while both lease and presence are
fresh at the atomic decision point.
5. A capability or ticket can cause at most one successful state transition for
its intended role and attempt.
6. Join authorization never bypasses host-owned final admission.
7. Input cannot create unbounded memory, work, response bytes, metric labels, or
log volume.
8. Raw endpoints and secrets never enter normal logs, traces, audit payloads, or
metric dimensions.
## Residual risk
Direct traversal cannot work through every NAT, firewall, carrier, or platform
policy. Rate limiting cannot eliminate distributed abuse. A compromised trusted
proxy, secret provider, operator identity, game grant issuer, or host credential
can act within its granted scope until detected and revoked. Unlisted share
codes can be disclosed by recipients. These risks are communicated as typed
outcomes and operational signals rather than hidden behind a success claim.
+7
View File
@@ -0,0 +1,7 @@
{
"sdk": {
"version": "10.0.301",
"rollForward": "disable",
"allowPrerelease": false
}
}
@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.1</TargetFramework>
<AssemblyName>FinalFactory.Rendezvous.Client</AssemblyName>
<RootNamespace>FinalFactory.Rendezvous.Client</RootNamespace>
<IsPackable>true</IsPackable>
<PackageId>FinalFactory.Rendezvous.Client</PackageId>
<Description>Godot-independent client SDK for Final Factory Rendezvous.</Description>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../FinalFactory.Rendezvous.Contracts/FinalFactory.Rendezvous.Contracts.csproj" />
<PackageReference Include="LiteNetLib" />
</ItemGroup>
</Project>
@@ -0,0 +1,79 @@
{
"version": 2,
"dependencies": {
".NETStandard,Version=v2.1": {
"LiteNetLib": {
"type": "Direct",
"requested": "[2.1.4, )",
"resolved": "2.1.4",
"contentHash": "KWlxvMw3Urpqj9joD96LRiK+LC62pQNs/zkXRJc+rHnxgkGp+vV703xzDrxRmv+V1YhCFfIGzs5nrVWtREIlyA=="
},
"Microsoft.Bcl.AsyncInterfaces": {
"type": "Transitive",
"resolved": "10.0.10",
"contentHash": "TFI6OKYE1XZz4SGuTSH70c6SBdPpFktXsoa1gCxTr3mKrhmXirnvaS0tKz+J3ZWICEAmMpEGn59nO4ICtUpQXA=="
},
"System.Buffers": {
"type": "Transitive",
"resolved": "4.6.1",
"contentHash": "N8GXpmiLMtljq7gwvyS+1QvKT/W2J8sNAvx+HVg4NGmsG/H+2k/y9QI23auLJRterrzCiDH+IWAw4V/GPwsMlw=="
},
"System.IO.Pipelines": {
"type": "Transitive",
"resolved": "10.0.10",
"contentHash": "7WX0W96y3dpQdYG4sEGdh38g3/0lOD4/dKbn2rRVOVzKhzoZUn2gKNIKaFeKWs8RCbpFfmmEWsRhSy95hMpvqA==",
"dependencies": {
"System.Buffers": "4.6.1",
"System.Memory": "4.6.3",
"System.Threading.Tasks.Extensions": "4.6.3"
}
},
"System.Memory": {
"type": "Transitive",
"resolved": "4.6.3",
"contentHash": "qdcDOgnFZY40+Q9876JUHnlHu7bosOHX8XISRoH94fwk6hgaeQGSgfZd8srWRZNt5bV9ZW2TljcegDNxsf+96A=="
},
"System.Runtime.CompilerServices.Unsafe": {
"type": "Transitive",
"resolved": "6.1.2",
"contentHash": "2hBr6zdbIBTDE3EhK7NSVNdX58uTK6iHW/P/Axmm9sl1xoGSLqDvMtpecn226TNwHByFokYwJmt/aQQNlO5CRw=="
},
"System.Text.Encodings.Web": {
"type": "Transitive",
"resolved": "10.0.10",
"contentHash": "o16m2YpDN/pjHsnxf9pTGwkpcuvjW8v1/wGUwJtM1c3QZUKm7ZEO/eYRJg7iIx6GxS2Zv9lAMHpiQwHDdgqauA==",
"dependencies": {
"System.Buffers": "4.6.1",
"System.Memory": "4.6.3",
"System.Runtime.CompilerServices.Unsafe": "6.1.2"
}
},
"System.Threading.Tasks.Extensions": {
"type": "Transitive",
"resolved": "4.6.3",
"contentHash": "7sCiwilJLYbTZELaKnc7RecBBXWXA+xMLQWZKWawBxYjp6DBlSE3v9/UcvKBvr1vv2tTOhipiogM8rRmxlhrVA=="
},
"finalfactory.rendezvous.contracts": {
"type": "Project",
"dependencies": {
"System.Text.Json": "[10.0.10, )"
}
},
"System.Text.Json": {
"type": "CentralTransitive",
"requested": "[10.0.10, )",
"resolved": "10.0.10",
"contentHash": "bmsO6UdYtBdtn32zYXfsh7KlyTIzV/3V9hdT9RIb4pXKgYOsNxXR+VbWigNwBtNFVGYGm6Hwmqw5a+/IWFd36Q==",
"dependencies": {
"Microsoft.Bcl.AsyncInterfaces": "10.0.10",
"System.Buffers": "4.6.1",
"System.IO.Pipelines": "10.0.10",
"System.Memory": "4.6.3",
"System.Runtime.CompilerServices.Unsafe": "6.1.2",
"System.Text.Encodings.Web": "10.0.10",
"System.Threading.Tasks.Extensions": "4.6.3"
}
}
}
}
}
@@ -0,0 +1,75 @@
namespace FinalFactory.Rendezvous.Contracts;
public enum RendezvousErrorCode
{
None = 0,
InvalidRequest = 1,
UnsupportedContractVersion = 2,
IncompatibleProtocol = 3,
AuthenticationRequired = 4,
Forbidden = 5,
NotFound = 6,
Conflict = 7,
RateLimited = 8,
StaleHost = 9,
Expired = 10,
ReplayRejected = 11,
CapacityExceeded = 12,
ServiceUnavailable = 13,
InternalError = 14,
}
public enum ListingVisibility
{
Public = 1,
Unlisted = 2,
}
public enum PublisherTrustMode
{
ManagedDedicated = 1,
PlayerGrant = 2,
AnonymousUnlisted = 3,
}
public enum AddressFamilyKind
{
Ipv4 = 4,
Ipv6 = 6,
}
public enum ConnectionOutcomeKind
{
Connected = 1,
Cancelled = 2,
TimedOut = 3,
IncompatibleProtocol = 4,
StaleHost = 5,
ServiceRejected = 6,
HostRejected = 7,
TransportFailed = 8,
FallbackOffered = 9,
}
public enum UdpPresenceMessageType : byte
{
HostPresence = 1,
ClientPresence = 2,
}
public enum UdpDecodeError
{
None = 0,
DatagramTooLarge = 1,
Truncated = 2,
InvalidMagic = 3,
UnsupportedVersion = 4,
UnknownMessageType = 5,
InvalidFlags = 6,
InvalidHandle = 7,
InvalidAddressFamily = 8,
InvalidAddress = 9,
InvalidPort = 10,
InvalidCapability = 11,
TrailingData = 12,
}
@@ -0,0 +1,28 @@
namespace FinalFactory.Rendezvous.Contracts;
public static class ContractLimits
{
public const int ContractVersion = 1;
public const int HttpRequestMaxBytes = 16 * 1024;
public const int BrowserResponseMaxBytes = 256 * 1024;
public const int UdpDatagramMaxBytes = 1_200;
public const int MetadataMaxBytes = 4 * 1024;
public const int MetadataMaxKeys = 32;
public const int MetadataKeyMaxBytes = 64;
public const int MetadataValueMaxBytes = 256;
public const int BrowserPageMaxItems = 100;
public const int GameIdMaxCharacters = 64;
public const int EnvironmentIdMaxCharacters = 32;
public const int RegionIdMaxCharacters = 32;
public const int DisplayNameMaxBytes = 128;
public const int BuildVersionMaxBytes = 64;
public const int IdempotencyKeyMaxCharacters = 64;
public const int CursorMaxCharacters = 512;
public const int DiagnosticCodeMaxCharacters = 64;
public const int ErrorMessageMaxBytes = 256;
public const int OpaqueHttpCredentialMaxCharacters = 1_024;
public const int UdpCapabilityMaxCharacters = 192;
public const int ConnectionTicketMaxCharacters = 192;
public const int LiteNetLibNatTokenMaxCharacters = 256;
public const int SessionCapacityMaxPlayers = 10_000;
}
@@ -0,0 +1,47 @@
using System.Text.Json.Serialization;
namespace FinalFactory.Rendezvous.Contracts;
public sealed class SessionCapacity
{
[JsonRequired]
public int CurrentPlayers { get; set; }
[JsonRequired]
public int MaximumPlayers { get; set; }
}
public sealed class NetworkEndpoint
{
[JsonRequired]
public AddressFamilyKind AddressFamily { get; set; }
[JsonRequired]
public string Address { get; set; } = string.Empty;
[JsonRequired]
public int Port { get; set; }
}
public sealed class ApiError
{
[JsonRequired]
public int ContractVersion { get; set; } = ContractLimits.ContractVersion;
[JsonRequired]
public RendezvousErrorCode Code { get; set; }
[JsonRequired]
public string Message { get; set; } = string.Empty;
public string? CorrelationId { get; set; }
public int? RetryAfterSeconds { get; set; }
}
public sealed class HealthResponse
{
[JsonRequired]
public int ContractVersion { get; set; } = ContractLimits.ContractVersion;
[JsonRequired]
public string Status { get; set; } = string.Empty;
}
@@ -0,0 +1,134 @@
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Text.Json;
namespace FinalFactory.Rendezvous.Contracts;
public static class ContractValidation
{
private const string CapabilityAlphabet =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
public static RendezvousErrorCode ValidateContractVersion(int contractVersion) =>
contractVersion == ContractLimits.ContractVersion
? RendezvousErrorCode.None
: RendezvousErrorCode.UnsupportedContractVersion;
public static bool AreProtocolsCompatible(uint requested, uint offered) => requested == offered;
public static bool IsHttpRequestSizeValid(int byteCount) =>
byteCount is >= 0 and <= ContractLimits.HttpRequestMaxBytes;
public static bool IsBrowserResponseSizeValid(int byteCount) =>
byteCount is >= 0 and <= ContractLimits.BrowserResponseMaxBytes;
public static bool IsUtf8LengthWithin(string? value, int maximumBytes)
{
if (maximumBytes < 0)
{
throw new ArgumentOutOfRangeException(nameof(maximumBytes));
}
return value is not null && Encoding.UTF8.GetByteCount(value) <= maximumBytes;
}
public static bool IsPageSizeValid(int pageSize) =>
pageSize is >= 1 and <= ContractLimits.BrowserPageMaxItems;
public static bool IsIdempotencyKeyValid(string? value) =>
IsVisibleAsciiWithin(value, ContractLimits.IdempotencyKeyMaxCharacters);
public static bool IsCursorValid(string? value) =>
value is null || IsVisibleAsciiWithin(value, ContractLimits.CursorMaxCharacters);
public static bool IsDiagnosticCodeValid(string? value) =>
value is null || IsVisibleAsciiWithin(value, ContractLimits.DiagnosticCodeMaxCharacters);
public static bool IsBuildVersionValid(string? value) =>
IsUtf8LengthWithin(value, ContractLimits.BuildVersionMaxBytes);
public static bool IsDisplayNameValid(string? value) =>
IsUtf8LengthWithin(value, ContractLimits.DisplayNameMaxBytes);
public static bool IsOpaqueHttpCredentialValid(string? value) =>
value is not null
&& value.Length is > 0 and <= ContractLimits.OpaqueHttpCredentialMaxCharacters;
public static bool IsCapacityValid(SessionCapacity? capacity) =>
capacity is not null
&& capacity.MaximumPlayers is >= 1 and <= ContractLimits.SessionCapacityMaxPlayers
&& capacity.CurrentPlayers >= 0
&& capacity.CurrentPlayers <= capacity.MaximumPlayers;
public static bool IsCapabilityValid(string? capability) =>
IsBase64UrlValueValid(capability, ContractLimits.UdpCapabilityMaxCharacters);
public static bool IsConnectionTicketValid(string? ticket) =>
IsBase64UrlValueValid(ticket, ContractLimits.ConnectionTicketMaxCharacters);
public static bool IsNetworkEndpointValid(NetworkEndpoint? endpoint)
{
if (endpoint is null
|| endpoint.Port is < 1 or > ushort.MaxValue
|| !IPAddress.TryParse(endpoint.Address, out IPAddress? address))
{
return false;
}
return endpoint.AddressFamily switch
{
AddressFamilyKind.Ipv4 => address.AddressFamily == AddressFamily.InterNetwork,
AddressFamilyKind.Ipv6 => address.AddressFamily == AddressFamily.InterNetworkV6,
_ => false,
};
}
public static bool IsMetadataValid(IReadOnlyDictionary<string, string>? metadata)
{
if (metadata is null || metadata.Count > ContractLimits.MetadataMaxKeys)
{
return false;
}
foreach (KeyValuePair<string, string> item in metadata)
{
if (string.IsNullOrWhiteSpace(item.Key)
|| !IsUtf8LengthWithin(item.Key, ContractLimits.MetadataKeyMaxBytes)
|| !IsUtf8LengthWithin(item.Value, ContractLimits.MetadataValueMaxBytes))
{
return false;
}
}
byte[] encoded = JsonSerializer.SerializeToUtf8Bytes(metadata, ContractJson.Options);
return encoded.Length <= ContractLimits.MetadataMaxBytes;
}
internal static bool IsSlug(string? value, int maximumCharacters)
{
if (string.IsNullOrEmpty(value)
|| value.Length > maximumCharacters
|| value[0] is < 'a' or > 'z')
{
return false;
}
return value.All(static character =>
character is >= 'a' and <= 'z'
or >= '0' and <= '9'
or '-');
}
private static bool IsBase64UrlValueValid(string? value, int maximumCharacters) =>
value is not null
&& value.Length is > 0
&& value.Length <= maximumCharacters
&& value.All(static character => CapabilityAlphabet.Contains(character));
private static bool IsVisibleAsciiWithin(string? value, int maximumCharacters) =>
value is not null
&& value.Length is > 0
&& value.Length <= maximumCharacters
&& value.All(static character => character is >= '!' and <= '~');
}
@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.1</TargetFramework>
<AssemblyName>FinalFactory.Rendezvous.Contracts</AssemblyName>
<RootNamespace>FinalFactory.Rendezvous.Contracts</RootNamespace>
<IsPackable>true</IsPackable>
<PackageId>FinalFactory.Rendezvous.Contracts</PackageId>
<Description>Versioned transport-neutral contracts for Final Factory Rendezvous.</Description>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="System.Text.Json" />
</ItemGroup>
</Project>
@@ -0,0 +1,92 @@
using System.Text.Json.Serialization;
namespace FinalFactory.Rendezvous.Contracts;
public sealed class CreateJoinAttemptRequest
{
[JsonRequired]
public int ContractVersion { get; set; } = ContractLimits.ContractVersion;
[JsonRequired]
public string IdempotencyKey { get; set; } = string.Empty;
[JsonRequired]
public GameId GameId { get; set; }
[JsonRequired]
public EnvironmentId EnvironmentId { get; set; }
[JsonRequired]
public SessionListingId ListingId { get; set; }
[JsonRequired]
public uint ProtocolVersion { get; set; }
}
public sealed class CreateJoinAttemptResponse
{
[JsonRequired]
public int ContractVersion { get; set; } = ContractLimits.ContractVersion;
[JsonRequired]
public JoinAttemptId AttemptId { get; set; }
[JsonRequired]
public MediationHandle MediationHandle { get; set; }
[JsonRequired]
public string ClientPunchCapability { get; set; } = string.Empty;
[JsonRequired]
public DateTimeOffset ExpiresAt { get; set; }
public NetworkEndpoint? DedicatedFallback { get; set; }
}
public sealed class HostJoinAttempt
{
[JsonRequired]
public JoinAttemptId AttemptId { get; set; }
[JsonRequired]
public MediationHandle MediationHandle { get; set; }
[JsonRequired]
public string HostPunchCapability { get; set; } = string.Empty;
[JsonRequired]
public DateTimeOffset ExpiresAt { get; set; }
}
public sealed class BrowseHostJoinAttemptsResponse
{
[JsonRequired]
public int ContractVersion { get; set; } = ContractLimits.ContractVersion;
[JsonRequired]
public List<HostJoinAttempt> Items { get; set; } = [];
public string? NextCursor { get; set; }
}
public sealed class ReportConnectionOutcomeRequest
{
[JsonRequired]
public int ContractVersion { get; set; } = ContractLimits.ContractVersion;
[JsonRequired]
public ConnectionOutcomeKind Outcome { get; set; }
[JsonRequired]
public int ElapsedMilliseconds { get; set; }
public string? DiagnosticCode { get; set; }
}
public sealed class ReportConnectionOutcomeResponse
{
[JsonRequired]
public int ContractVersion { get; set; } = ContractLimits.ContractVersion;
[JsonRequired]
public bool Accepted { get; set; }
}
@@ -0,0 +1,189 @@
using System.Text.Json.Serialization;
namespace FinalFactory.Rendezvous.Contracts;
public sealed class SessionListing
{
[JsonRequired]
public int ContractVersion { get; set; } = ContractLimits.ContractVersion;
[JsonRequired]
public SessionListingId ListingId { get; set; }
[JsonRequired]
public GameId GameId { get; set; }
[JsonRequired]
public EnvironmentId EnvironmentId { get; set; }
[JsonRequired]
public RegionId RegionId { get; set; }
[JsonRequired]
public uint ProtocolVersion { get; set; }
[JsonRequired]
public string BuildVersion { get; set; } = string.Empty;
[JsonRequired]
public string DisplayName { get; set; } = string.Empty;
[JsonRequired]
public ListingVisibility Visibility { get; set; }
[JsonRequired]
public PublisherTrustMode PublisherTrustMode { get; set; }
[JsonRequired]
public SessionCapacity Capacity { get; set; } = new();
[JsonRequired]
public Dictionary<string, string> Metadata { get; set; } = new(StringComparer.Ordinal);
}
public sealed class RegisterSessionRequest
{
[JsonRequired]
public int ContractVersion { get; set; } = ContractLimits.ContractVersion;
[JsonRequired]
public string IdempotencyKey { get; set; } = string.Empty;
[JsonRequired]
public GameId GameId { get; set; }
[JsonRequired]
public EnvironmentId EnvironmentId { get; set; }
[JsonRequired]
public RegionId RegionId { get; set; }
[JsonRequired]
public uint ProtocolVersion { get; set; }
[JsonRequired]
public string BuildVersion { get; set; } = string.Empty;
[JsonRequired]
public string DisplayName { get; set; } = string.Empty;
[JsonRequired]
public ListingVisibility Visibility { get; set; }
[JsonRequired]
public SessionCapacity Capacity { get; set; } = new();
[JsonRequired]
public Dictionary<string, string> Metadata { get; set; } = new(StringComparer.Ordinal);
}
public sealed class RegisterSessionResponse
{
[JsonRequired]
public int ContractVersion { get; set; } = ContractLimits.ContractVersion;
[JsonRequired]
public SessionListingId ListingId { get; set; }
[JsonRequired]
public LeaseId LeaseId { get; set; }
[JsonRequired]
public string LeaseToken { get; set; } = string.Empty;
[JsonRequired]
public MediationHandle HostPresenceHandle { get; set; }
[JsonRequired]
public string HostPresenceCapability { get; set; } = string.Empty;
[JsonRequired]
public DateTimeOffset ExpiresAt { get; set; }
}
public sealed class RenewLeaseRequest
{
[JsonRequired]
public int ContractVersion { get; set; } = ContractLimits.ContractVersion;
[JsonRequired]
public string LeaseToken { get; set; } = string.Empty;
}
public sealed class RenewLeaseResponse
{
[JsonRequired]
public int ContractVersion { get; set; } = ContractLimits.ContractVersion;
[JsonRequired]
public DateTimeOffset ExpiresAt { get; set; }
}
public sealed class UpdateSessionRequest
{
[JsonRequired]
public int ContractVersion { get; set; } = ContractLimits.ContractVersion;
[JsonRequired]
public string LeaseToken { get; set; } = string.Empty;
[JsonRequired]
public string BuildVersion { get; set; } = string.Empty;
[JsonRequired]
public string DisplayName { get; set; } = string.Empty;
[JsonRequired]
public SessionCapacity Capacity { get; set; } = new();
[JsonRequired]
public Dictionary<string, string> Metadata { get; set; } = new(StringComparer.Ordinal);
}
public sealed class DeleteSessionRequest
{
[JsonRequired]
public int ContractVersion { get; set; } = ContractLimits.ContractVersion;
[JsonRequired]
public string LeaseToken { get; set; } = string.Empty;
}
public sealed class BrowseSessionsRequest
{
[JsonRequired]
public int ContractVersion { get; set; } = ContractLimits.ContractVersion;
[JsonRequired]
public GameId GameId { get; set; }
[JsonRequired]
public EnvironmentId EnvironmentId { get; set; }
[JsonRequired]
public uint ProtocolVersion { get; set; }
public RegionId? RegionId { get; set; }
public int PageSize { get; set; } = ContractLimits.BrowserPageMaxItems;
public string? Cursor { get; set; }
}
public sealed class BrowseSessionsResponse
{
[JsonRequired]
public int ContractVersion { get; set; } = ContractLimits.ContractVersion;
[JsonRequired]
public List<SessionListing> Items { get; set; } = [];
public string? NextCursor { get; set; }
}
public sealed class GetSessionResponse
{
[JsonRequired]
public int ContractVersion { get; set; } = ContractLimits.ContractVersion;
[JsonRequired]
public SessionListing Session { get; set; } = new();
}
@@ -0,0 +1,112 @@
using System.Text.Json.Serialization;
namespace FinalFactory.Rendezvous.Contracts;
[JsonConverter(typeof(SessionListingIdJsonConverter))]
public readonly struct SessionListingId : IEquatable<SessionListingId>
{
public SessionListingId(Guid value) => Value = GuidIdentifier.RequireNonEmpty(value, nameof(value));
public Guid Value { get; }
public static bool TryParse(string? value, out SessionListingId id) =>
GuidIdentifier.TryParse(value, static guid => new SessionListingId(guid), out id);
public bool Equals(SessionListingId other) => Value.Equals(other.Value);
public override bool Equals(object? obj) => obj is SessionListingId other && Equals(other);
public override int GetHashCode() => Value.GetHashCode();
public override string ToString() => Value.ToString("D");
public static bool operator ==(SessionListingId left, SessionListingId right) => left.Equals(right);
public static bool operator !=(SessionListingId left, SessionListingId right) => !left.Equals(right);
}
[JsonConverter(typeof(LeaseIdJsonConverter))]
public readonly struct LeaseId : IEquatable<LeaseId>
{
public LeaseId(Guid value) => Value = GuidIdentifier.RequireNonEmpty(value, nameof(value));
public Guid Value { get; }
public static bool TryParse(string? value, out LeaseId id) =>
GuidIdentifier.TryParse(value, static guid => new LeaseId(guid), out id);
public bool Equals(LeaseId other) => Value.Equals(other.Value);
public override bool Equals(object? obj) => obj is LeaseId other && Equals(other);
public override int GetHashCode() => Value.GetHashCode();
public override string ToString() => Value.ToString("D");
public static bool operator ==(LeaseId left, LeaseId right) => left.Equals(right);
public static bool operator !=(LeaseId left, LeaseId right) => !left.Equals(right);
}
[JsonConverter(typeof(JoinAttemptIdJsonConverter))]
public readonly struct JoinAttemptId : IEquatable<JoinAttemptId>
{
public JoinAttemptId(Guid value) => Value = GuidIdentifier.RequireNonEmpty(value, nameof(value));
public Guid Value { get; }
public static bool TryParse(string? value, out JoinAttemptId id) =>
GuidIdentifier.TryParse(value, static guid => new JoinAttemptId(guid), out id);
public bool Equals(JoinAttemptId other) => Value.Equals(other.Value);
public override bool Equals(object? obj) => obj is JoinAttemptId other && Equals(other);
public override int GetHashCode() => Value.GetHashCode();
public override string ToString() => Value.ToString("D");
public static bool operator ==(JoinAttemptId left, JoinAttemptId right) => left.Equals(right);
public static bool operator !=(JoinAttemptId left, JoinAttemptId right) => !left.Equals(right);
}
[JsonConverter(typeof(MediationHandleJsonConverter))]
public readonly struct MediationHandle : IEquatable<MediationHandle>
{
public MediationHandle(Guid value) => Value = GuidIdentifier.RequireNonEmpty(value, nameof(value));
public Guid Value { get; }
public static bool TryParse(string? value, out MediationHandle id) =>
GuidIdentifier.TryParse(value, static guid => new MediationHandle(guid), out id);
public bool Equals(MediationHandle other) => Value.Equals(other.Value);
public override bool Equals(object? obj) => obj is MediationHandle other && Equals(other);
public override int GetHashCode() => Value.GetHashCode();
public override string ToString() => Value.ToString("D");
public static bool operator ==(MediationHandle left, MediationHandle right) => left.Equals(right);
public static bool operator !=(MediationHandle left, MediationHandle right) => !left.Equals(right);
}
internal static class GuidIdentifier
{
public static Guid RequireNonEmpty(Guid value, string parameterName) =>
value != Guid.Empty
? value
: throw new ArgumentException("Opaque identifiers cannot be empty.", parameterName);
public static bool TryParse<TIdentifier>(
string? value,
Func<Guid, TIdentifier> factory,
out TIdentifier identifier)
{
if (Guid.TryParseExact(value, "D", out Guid guid) && guid != Guid.Empty)
{
identifier = factory(guid);
return true;
}
identifier = default!;
return false;
}
}
internal abstract class GuidIdentifierJsonConverter<TIdentifier> :
StringIdentifierJsonConverter<TIdentifier>
{
protected sealed override string Format(TIdentifier value) => value?.ToString() ?? string.Empty;
}
internal sealed class SessionListingIdJsonConverter : GuidIdentifierJsonConverter<SessionListingId>
{
protected override SessionListingId Parse(string value) => new(Guid.ParseExact(value, "D"));
}
internal sealed class LeaseIdJsonConverter : GuidIdentifierJsonConverter<LeaseId>
{
protected override LeaseId Parse(string value) => new(Guid.ParseExact(value, "D"));
}
internal sealed class JoinAttemptIdJsonConverter : GuidIdentifierJsonConverter<JoinAttemptId>
{
protected override JoinAttemptId Parse(string value) => new(Guid.ParseExact(value, "D"));
}
internal sealed class MediationHandleJsonConverter : GuidIdentifierJsonConverter<MediationHandle>
{
protected override MediationHandle Parse(string value) => new(Guid.ParseExact(value, "D"));
}
@@ -0,0 +1,126 @@
using System.Text.Json.Serialization;
namespace FinalFactory.Rendezvous.Contracts;
[JsonConverter(typeof(GameIdJsonConverter))]
public readonly struct GameId : IEquatable<GameId>
{
public GameId(string value)
{
if (!ContractValidation.IsSlug(value, ContractLimits.GameIdMaxCharacters))
{
throw new ArgumentException("Game IDs must be lowercase URL-safe slugs.", nameof(value));
}
Value = value;
}
public string Value { get; }
public static bool TryParse(string? value, out GameId gameId)
{
if (ContractValidation.IsSlug(value, ContractLimits.GameIdMaxCharacters))
{
gameId = new GameId(value!);
return true;
}
gameId = default;
return false;
}
public bool Equals(GameId other) => string.Equals(Value, other.Value, StringComparison.Ordinal);
public override bool Equals(object? obj) => obj is GameId other && Equals(other);
public override int GetHashCode() => StringComparer.Ordinal.GetHashCode(Value ?? string.Empty);
public override string ToString() => Value ?? string.Empty;
public static bool operator ==(GameId left, GameId right) => left.Equals(right);
public static bool operator !=(GameId left, GameId right) => !left.Equals(right);
}
[JsonConverter(typeof(EnvironmentIdJsonConverter))]
public readonly struct EnvironmentId : IEquatable<EnvironmentId>
{
public EnvironmentId(string value)
{
if (!ContractValidation.IsSlug(value, ContractLimits.EnvironmentIdMaxCharacters))
{
throw new ArgumentException("Environment IDs must be lowercase URL-safe slugs.", nameof(value));
}
Value = value;
}
public string Value { get; }
public static bool TryParse(string? value, out EnvironmentId environmentId)
{
if (ContractValidation.IsSlug(value, ContractLimits.EnvironmentIdMaxCharacters))
{
environmentId = new EnvironmentId(value!);
return true;
}
environmentId = default;
return false;
}
public bool Equals(EnvironmentId other) => string.Equals(Value, other.Value, StringComparison.Ordinal);
public override bool Equals(object? obj) => obj is EnvironmentId other && Equals(other);
public override int GetHashCode() => StringComparer.Ordinal.GetHashCode(Value ?? string.Empty);
public override string ToString() => Value ?? string.Empty;
public static bool operator ==(EnvironmentId left, EnvironmentId right) => left.Equals(right);
public static bool operator !=(EnvironmentId left, EnvironmentId right) => !left.Equals(right);
}
[JsonConverter(typeof(RegionIdJsonConverter))]
public readonly struct RegionId : IEquatable<RegionId>
{
public RegionId(string value)
{
if (!ContractValidation.IsSlug(value, ContractLimits.RegionIdMaxCharacters))
{
throw new ArgumentException("Region IDs must be lowercase URL-safe slugs.", nameof(value));
}
Value = value;
}
public string Value { get; }
public static bool TryParse(string? value, out RegionId regionId)
{
if (ContractValidation.IsSlug(value, ContractLimits.RegionIdMaxCharacters))
{
regionId = new RegionId(value!);
return true;
}
regionId = default;
return false;
}
public bool Equals(RegionId other) => string.Equals(Value, other.Value, StringComparison.Ordinal);
public override bool Equals(object? obj) => obj is RegionId other && Equals(other);
public override int GetHashCode() => StringComparer.Ordinal.GetHashCode(Value ?? string.Empty);
public override string ToString() => Value ?? string.Empty;
public static bool operator ==(RegionId left, RegionId right) => left.Equals(right);
public static bool operator !=(RegionId left, RegionId right) => !left.Equals(right);
}
internal sealed class GameIdJsonConverter : StringIdentifierJsonConverter<GameId>
{
protected override GameId Parse(string value) => new(value);
protected override string Format(GameId value) => value.Value;
}
internal sealed class EnvironmentIdJsonConverter : StringIdentifierJsonConverter<EnvironmentId>
{
protected override EnvironmentId Parse(string value) => new(value);
protected override string Format(EnvironmentId value) => value.Value;
}
internal sealed class RegionIdJsonConverter : StringIdentifierJsonConverter<RegionId>
{
protected override RegionId Parse(string value) => new(value);
protected override string Format(RegionId value) => value.Value;
}
@@ -0,0 +1,37 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace FinalFactory.Rendezvous.Contracts;
internal abstract class StringIdentifierJsonConverter<TIdentifier> : JsonConverter<TIdentifier>
{
public sealed override TIdentifier Read(
ref Utf8JsonReader reader,
Type typeToConvert,
JsonSerializerOptions options)
{
if (reader.TokenType != JsonTokenType.String)
{
throw new JsonException($"{typeof(TIdentifier).Name} must be a JSON string.");
}
string value = reader.GetString() ?? string.Empty;
try
{
return Parse(value);
}
catch (Exception exception) when (exception is ArgumentException or FormatException)
{
throw new JsonException($"Invalid {typeof(TIdentifier).Name}.", exception);
}
}
public sealed override void Write(
Utf8JsonWriter writer,
TIdentifier value,
JsonSerializerOptions options) => writer.WriteStringValue(Format(value));
protected abstract TIdentifier Parse(string value);
protected abstract string Format(TIdentifier value);
}
@@ -0,0 +1,49 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace FinalFactory.Rendezvous.Contracts;
public static class ContractJson
{
private static readonly JsonSerializerOptions SharedOptions = CreateReadOnlyOptions();
public static JsonSerializerOptions Options => SharedOptions;
public static JsonSerializerOptions CreateOptions()
{
JsonSerializerOptions options = new(JsonSerializerDefaults.Web);
Configure(options);
return options;
}
public static void Configure(JsonSerializerOptions options)
{
if (options is null)
{
throw new ArgumentNullException(nameof(options));
}
options.AllowTrailingCommas = false;
options.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
options.MaxDepth = 8;
options.NumberHandling = JsonNumberHandling.Strict;
options.PropertyNameCaseInsensitive = false;
options.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
options.ReadCommentHandling = JsonCommentHandling.Disallow;
options.UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip;
options.WriteIndented = false;
if (!options.Converters.OfType<JsonStringEnumConverter>().Any())
{
options.Converters.Add(
new JsonStringEnumConverter(JsonNamingPolicy.CamelCase, allowIntegerValues: false));
}
}
private static JsonSerializerOptions CreateReadOnlyOptions()
{
JsonSerializerOptions options = CreateOptions();
options.MakeReadOnly(populateMissingResolver: true);
return options;
}
}
@@ -0,0 +1,12 @@
namespace FinalFactory.Rendezvous.Contracts;
public sealed class PresenceDatagram
{
public int ContractVersion { get; set; } = ContractLimits.ContractVersion;
public UdpPresenceMessageType MessageType { get; set; }
public MediationHandle MediationHandle { get; set; }
public AddressFamilyKind AddressFamily { get; set; }
public string LocalAddress { get; set; } = string.Empty;
public int LocalPort { get; set; }
public string Capability { get; set; } = string.Empty;
}
@@ -0,0 +1,281 @@
using System.Net;
using System.Net.Sockets;
using System.Text;
namespace FinalFactory.Rendezvous.Contracts;
public static class RendezvousUdpCodec
{
public const byte MagicFirst = 0x52;
public const byte MagicSecond = 0x56;
public const byte FlagsNone = 0;
private const int FixedPrefixLength = 23;
private const int FixedSuffixLength = 3;
public static byte[] Encode(PresenceDatagram datagram)
{
if (datagram is null)
{
throw new ArgumentNullException(nameof(datagram));
}
if (ContractValidation.ValidateContractVersion(datagram.ContractVersion)
!= RendezvousErrorCode.None)
{
throw new ArgumentException("The UDP contract version is unsupported.", nameof(datagram));
}
if (datagram.MessageType is not UdpPresenceMessageType.HostPresence
and not UdpPresenceMessageType.ClientPresence)
{
throw new ArgumentException("The UDP presence message type is unknown.", nameof(datagram));
}
if (datagram.MediationHandle.Value == Guid.Empty)
{
throw new ArgumentException("The mediation handle cannot be empty.", nameof(datagram));
}
if (!TryGetAddressBytes(datagram.LocalAddress, datagram.AddressFamily, out byte[] addressBytes))
{
throw new ArgumentException("The local address does not match its address family.", nameof(datagram));
}
if (datagram.LocalPort is < 1 or > ushort.MaxValue)
{
throw new ArgumentOutOfRangeException(nameof(datagram), "The local port must be between 1 and 65535.");
}
if (!ContractValidation.IsCapabilityValid(datagram.Capability))
{
throw new ArgumentException("The UDP capability is invalid.", nameof(datagram));
}
byte[] capabilityBytes = Encoding.ASCII.GetBytes(datagram.Capability);
int encodedLength = FixedPrefixLength + addressBytes.Length + FixedSuffixLength
+ capabilityBytes.Length;
if (encodedLength > ContractLimits.UdpDatagramMaxBytes)
{
throw new ArgumentException("The encoded UDP datagram exceeds its size limit.", nameof(datagram));
}
byte[] encoded = new byte[encodedLength];
int offset = 0;
encoded[offset++] = MagicFirst;
encoded[offset++] = MagicSecond;
encoded[offset++] = checked((byte)datagram.ContractVersion);
encoded[offset++] = (byte)datagram.MessageType;
encoded[offset++] = FlagsNone;
WriteGuid(datagram.MediationHandle.Value, encoded, offset);
offset += 16;
encoded[offset++] = (byte)datagram.AddressFamily;
encoded[offset++] = checked((byte)addressBytes.Length);
addressBytes.CopyTo(encoded, offset);
offset += addressBytes.Length;
encoded[offset++] = checked((byte)(datagram.LocalPort >> 8));
encoded[offset++] = checked((byte)(datagram.LocalPort & 0xff));
encoded[offset++] = checked((byte)capabilityBytes.Length);
capabilityBytes.CopyTo(encoded, offset);
return encoded;
}
public static bool TryDecode(
ReadOnlySpan<byte> encoded,
out PresenceDatagram? datagram,
out UdpDecodeError error)
{
datagram = null;
error = UdpDecodeError.None;
if (encoded.Length > ContractLimits.UdpDatagramMaxBytes)
{
error = UdpDecodeError.DatagramTooLarge;
return false;
}
if (encoded.Length < FixedPrefixLength)
{
error = UdpDecodeError.Truncated;
return false;
}
int offset = 0;
if (encoded[offset++] != MagicFirst || encoded[offset++] != MagicSecond)
{
error = UdpDecodeError.InvalidMagic;
return false;
}
int version = encoded[offset++];
if (ContractValidation.ValidateContractVersion(version) != RendezvousErrorCode.None)
{
error = UdpDecodeError.UnsupportedVersion;
return false;
}
UdpPresenceMessageType messageType = (UdpPresenceMessageType)encoded[offset++];
if (messageType is not UdpPresenceMessageType.HostPresence
and not UdpPresenceMessageType.ClientPresence)
{
error = UdpDecodeError.UnknownMessageType;
return false;
}
if (encoded[offset++] != FlagsNone)
{
error = UdpDecodeError.InvalidFlags;
return false;
}
if (!TryReadGuid(encoded.Slice(offset, 16), out Guid handle)
|| handle == Guid.Empty)
{
error = UdpDecodeError.InvalidHandle;
return false;
}
offset += 16;
AddressFamilyKind addressFamily = (AddressFamilyKind)encoded[offset++];
int expectedAddressLength = addressFamily switch
{
AddressFamilyKind.Ipv4 => 4,
AddressFamilyKind.Ipv6 => 16,
_ => 0,
};
if (expectedAddressLength == 0)
{
error = UdpDecodeError.InvalidAddressFamily;
return false;
}
int addressLength = encoded[offset++];
if (addressLength != expectedAddressLength)
{
error = UdpDecodeError.InvalidAddress;
return false;
}
if (encoded.Length < offset + addressLength + FixedSuffixLength)
{
error = UdpDecodeError.Truncated;
return false;
}
string address;
try
{
address = new IPAddress(encoded.Slice(offset, addressLength).ToArray()).ToString();
}
catch (ArgumentException)
{
error = UdpDecodeError.InvalidAddress;
return false;
}
offset += addressLength;
int port = (encoded[offset++] << 8) | encoded[offset++];
if (port == 0)
{
error = UdpDecodeError.InvalidPort;
return false;
}
int capabilityLength = encoded[offset++];
if (capabilityLength == 0 || capabilityLength > ContractLimits.UdpCapabilityMaxCharacters)
{
error = UdpDecodeError.InvalidCapability;
return false;
}
if (encoded.Length < offset + capabilityLength)
{
error = UdpDecodeError.Truncated;
return false;
}
if (encoded.Length > offset + capabilityLength)
{
error = UdpDecodeError.TrailingData;
return false;
}
string capability = Encoding.ASCII.GetString(encoded.Slice(offset, capabilityLength).ToArray());
if (!ContractValidation.IsCapabilityValid(capability))
{
error = UdpDecodeError.InvalidCapability;
return false;
}
datagram = new PresenceDatagram
{
ContractVersion = version,
MessageType = messageType,
MediationHandle = new MediationHandle(handle),
AddressFamily = addressFamily,
LocalAddress = address,
LocalPort = port,
Capability = capability,
};
return true;
}
private static bool TryGetAddressBytes(
string value,
AddressFamilyKind addressFamily,
out byte[] addressBytes)
{
addressBytes = [];
if (!IPAddress.TryParse(value, out IPAddress? address))
{
return false;
}
bool familyMatches = addressFamily switch
{
AddressFamilyKind.Ipv4 => address.AddressFamily == AddressFamily.InterNetwork,
AddressFamilyKind.Ipv6 => address.AddressFamily == AddressFamily.InterNetworkV6,
_ => false,
};
if (!familyMatches)
{
return false;
}
addressBytes = address.GetAddressBytes();
return true;
}
private static void WriteGuid(Guid value, byte[] destination, int offset)
{
string hexadecimal = value.ToString("N");
for (int index = 0; index < 16; index++)
{
int high = ParseHexadecimal(hexadecimal[index * 2]);
int low = ParseHexadecimal(hexadecimal[(index * 2) + 1]);
destination[offset + index] = checked((byte)((high << 4) | low));
}
}
private static bool TryReadGuid(ReadOnlySpan<byte> encoded, out Guid value)
{
char[] hexadecimal = new char[32];
for (int index = 0; index < encoded.Length; index++)
{
hexadecimal[index * 2] = FormatHexadecimal(encoded[index] >> 4);
hexadecimal[(index * 2) + 1] = FormatHexadecimal(encoded[index] & 0x0f);
}
return Guid.TryParseExact(new string(hexadecimal), "N", out value);
}
private static int ParseHexadecimal(char value) => value switch
{
>= '0' and <= '9' => value - '0',
>= 'a' and <= 'f' => value - 'a' + 10,
_ => throw new FormatException("A GUID contained a non-hexadecimal character."),
};
private static char FormatHexadecimal(int value) =>
(char)(value < 10 ? '0' + value : 'a' + value - 10);
}
@@ -0,0 +1,67 @@
{
"version": 2,
"dependencies": {
".NETStandard,Version=v2.1": {
"System.Text.Json": {
"type": "Direct",
"requested": "[10.0.10, )",
"resolved": "10.0.10",
"contentHash": "bmsO6UdYtBdtn32zYXfsh7KlyTIzV/3V9hdT9RIb4pXKgYOsNxXR+VbWigNwBtNFVGYGm6Hwmqw5a+/IWFd36Q==",
"dependencies": {
"Microsoft.Bcl.AsyncInterfaces": "10.0.10",
"System.Buffers": "4.6.1",
"System.IO.Pipelines": "10.0.10",
"System.Memory": "4.6.3",
"System.Runtime.CompilerServices.Unsafe": "6.1.2",
"System.Text.Encodings.Web": "10.0.10",
"System.Threading.Tasks.Extensions": "4.6.3"
}
},
"Microsoft.Bcl.AsyncInterfaces": {
"type": "Transitive",
"resolved": "10.0.10",
"contentHash": "TFI6OKYE1XZz4SGuTSH70c6SBdPpFktXsoa1gCxTr3mKrhmXirnvaS0tKz+J3ZWICEAmMpEGn59nO4ICtUpQXA=="
},
"System.Buffers": {
"type": "Transitive",
"resolved": "4.6.1",
"contentHash": "N8GXpmiLMtljq7gwvyS+1QvKT/W2J8sNAvx+HVg4NGmsG/H+2k/y9QI23auLJRterrzCiDH+IWAw4V/GPwsMlw=="
},
"System.IO.Pipelines": {
"type": "Transitive",
"resolved": "10.0.10",
"contentHash": "7WX0W96y3dpQdYG4sEGdh38g3/0lOD4/dKbn2rRVOVzKhzoZUn2gKNIKaFeKWs8RCbpFfmmEWsRhSy95hMpvqA==",
"dependencies": {
"System.Buffers": "4.6.1",
"System.Memory": "4.6.3",
"System.Threading.Tasks.Extensions": "4.6.3"
}
},
"System.Memory": {
"type": "Transitive",
"resolved": "4.6.3",
"contentHash": "qdcDOgnFZY40+Q9876JUHnlHu7bosOHX8XISRoH94fwk6hgaeQGSgfZd8srWRZNt5bV9ZW2TljcegDNxsf+96A=="
},
"System.Runtime.CompilerServices.Unsafe": {
"type": "Transitive",
"resolved": "6.1.2",
"contentHash": "2hBr6zdbIBTDE3EhK7NSVNdX58uTK6iHW/P/Axmm9sl1xoGSLqDvMtpecn226TNwHByFokYwJmt/aQQNlO5CRw=="
},
"System.Text.Encodings.Web": {
"type": "Transitive",
"resolved": "10.0.10",
"contentHash": "o16m2YpDN/pjHsnxf9pTGwkpcuvjW8v1/wGUwJtM1c3QZUKm7ZEO/eYRJg7iIx6GxS2Zv9lAMHpiQwHDdgqauA==",
"dependencies": {
"System.Buffers": "4.6.1",
"System.Memory": "4.6.3",
"System.Runtime.CompilerServices.Unsafe": "6.1.2"
}
},
"System.Threading.Tasks.Extensions": {
"type": "Transitive",
"resolved": "4.6.3",
"contentHash": "7sCiwilJLYbTZELaKnc7RecBBXWXA+xMLQWZKWawBxYjp6DBlSE3v9/UcvKBvr1vv2tTOhipiogM8rRmxlhrVA=="
}
}
}
}
@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<AssemblyName>FinalFactory.Rendezvous.Server</AssemblyName>
<RootNamespace>FinalFactory.Rendezvous.Server</RootNamespace>
<IsPackable>false</IsPackable>
<OpenApiGenerateDocuments>true</OpenApiGenerateDocuments>
<OpenApiDocumentsDirectory>$(MSBuildProjectDirectory)/../../docs/api</OpenApiDocumentsDirectory>
<OpenApiGenerateDocumentsOptions>--document-name v1 --file-name rendezvous-v1 --openapi-version OpenApi3_1</OpenApiGenerateDocumentsOptions>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../FinalFactory.Rendezvous.Contracts/FinalFactory.Rendezvous.Contracts.csproj" />
<PackageReference Include="LiteNetLib" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
<PackageReference Include="Microsoft.Extensions.ApiDescription.Server" PrivateAssets="all" />
</ItemGroup>
</Project>
@@ -0,0 +1,112 @@
using FinalFactory.Rendezvous.Contracts;
using Microsoft.AspNetCore.Mvc;
namespace FinalFactory.Rendezvous.Server.Http;
internal static class ContractEndpoints
{
private const int NotImplementedStatus = StatusCodes.Status501NotImplemented;
public static IEndpointRouteBuilder MapRendezvousContractEndpoints(
this IEndpointRouteBuilder endpoints)
{
RouteGroupBuilder sessions = endpoints.MapGroup("/v1/sessions").WithTags("Sessions");
sessions.MapPost("/", RegisterSession)
.Accepts<RegisterSessionRequest>("application/json")
.Produces<RegisterSessionResponse>(StatusCodes.Status201Created)
.Produces<ApiError>(NotImplementedStatus)
.WithName("RegisterSession");
sessions.MapPost("/{listingId}/renew", RenewLease)
.Accepts<RenewLeaseRequest>("application/json")
.Produces<RenewLeaseResponse>()
.Produces<ApiError>(NotImplementedStatus)
.WithName("RenewSessionLease");
sessions.MapPut("/{listingId}", UpdateSession)
.Accepts<UpdateSessionRequest>("application/json")
.Produces(StatusCodes.Status204NoContent)
.Produces<ApiError>(NotImplementedStatus)
.WithName("UpdateSession");
sessions.MapDelete("/{listingId}", DeleteSession)
.Accepts<DeleteSessionRequest>("application/json")
.Produces(StatusCodes.Status204NoContent)
.Produces<ApiError>(NotImplementedStatus)
.WithName("DeleteSession");
sessions.MapGet("/", BrowseSessions)
.Produces<BrowseSessionsResponse>()
.Produces<ApiError>(NotImplementedStatus)
.WithName("BrowseSessions");
sessions.MapGet("/{listingId}", GetSession)
.Produces<GetSessionResponse>()
.Produces<ApiError>(NotImplementedStatus)
.WithName("GetSession");
sessions.MapGet("/{listingId}/join-attempts", BrowseHostJoinAttempts)
.Produces<BrowseHostJoinAttemptsResponse>()
.Produces<ApiError>(NotImplementedStatus)
.WithName("BrowseHostJoinAttempts");
RouteGroupBuilder attempts = endpoints
.MapGroup("/v1/join-attempts")
.WithTags("Join attempts");
attempts.MapPost("/", CreateJoinAttempt)
.Accepts<CreateJoinAttemptRequest>("application/json")
.Produces<CreateJoinAttemptResponse>(StatusCodes.Status201Created)
.Produces<ApiError>(NotImplementedStatus)
.WithName("CreateJoinAttempt");
attempts.MapPost("/{attemptId}/outcome", ReportConnectionOutcome)
.Accepts<ReportConnectionOutcomeRequest>("application/json")
.Produces<ReportConnectionOutcomeResponse>()
.Produces<ApiError>(NotImplementedStatus)
.WithName("ReportConnectionOutcome");
return endpoints;
}
private static IResult RegisterSession([FromBody] RegisterSessionRequest request) =>
NotImplemented();
private static IResult RenewLease(
SessionListingId listingId,
[FromBody] RenewLeaseRequest request) => NotImplemented();
private static IResult UpdateSession(
SessionListingId listingId,
[FromBody] UpdateSessionRequest request) => NotImplemented();
private static IResult DeleteSession(
SessionListingId listingId,
[FromBody] DeleteSessionRequest request) => NotImplemented();
private static IResult BrowseSessions(
[FromQuery] int contractVersion,
[FromQuery] string gameId,
[FromQuery] string environmentId,
[FromQuery] uint protocolVersion,
[FromQuery] string? regionId,
[FromQuery] int? pageSize,
[FromQuery] string? cursor) => NotImplemented();
private static IResult GetSession(SessionListingId listingId) => NotImplemented();
private static IResult BrowseHostJoinAttempts(
SessionListingId listingId,
[FromQuery] int contractVersion,
[FromHeader(Name = "X-Rendezvous-Lease-Token")] string leaseToken,
[FromQuery] int? pageSize,
[FromQuery] string? cursor) => NotImplemented();
private static IResult CreateJoinAttempt([FromBody] CreateJoinAttemptRequest request) =>
NotImplemented();
private static IResult ReportConnectionOutcome(
JoinAttemptId attemptId,
[FromBody] ReportConnectionOutcomeRequest request) => NotImplemented();
private static IResult NotImplemented() => Results.Json(
new ApiError
{
Code = RendezvousErrorCode.ServiceUnavailable,
Message = "The v1 contract is reserved; implementation is tracked by subsequent issues.",
},
ContractJson.Options,
statusCode: NotImplementedStatus);
}
@@ -0,0 +1,77 @@
using System.Net;
using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.Http;
using FinalFactory.Rendezvous.Server.Transport;
using Microsoft.OpenApi;
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
bool isOpenApiGeneration = Environment.GetCommandLineArgs().Any(static argument =>
string.Equals(
Path.GetFileName(argument),
"dotnet-getdocument.dll",
StringComparison.OrdinalIgnoreCase));
builder.Services.AddOpenApi("v1", static options =>
options.AddSchemaTransformer(static (schema, context, cancellationToken) =>
{
Type type = context.JsonTypeInfo.Type;
if (type == typeof(GameId)
|| type == typeof(EnvironmentId)
|| type == typeof(RegionId))
{
schema.Type = JsonSchemaType.String;
}
else if (type == typeof(SessionListingId)
|| type == typeof(LeaseId)
|| type == typeof(JoinAttemptId)
|| type == typeof(MediationHandle))
{
schema.Type = JsonSchemaType.String;
schema.Format = "uuid";
}
return Task.CompletedTask;
}));
builder.Services.ConfigureHttpJsonOptions(static options =>
ContractJson.Configure(options.SerializerOptions));
builder.Services
.AddOptions<UdpMediatorOptions>()
.BindConfiguration(UdpMediatorOptions.SectionName)
.ValidateDataAnnotations()
.Validate(
options => IPAddress.TryParse(options.ListenAddress, out _),
$"{UdpMediatorOptions.SectionName}:ListenAddress must be an IP address.")
.ValidateOnStart();
builder.Services.AddSingleton<UdpMediatorService>();
if (!isOpenApiGeneration)
{
builder.Services.AddHostedService(static services =>
services.GetRequiredService<UdpMediatorService>());
}
WebApplication app = builder.Build();
app.MapOpenApi();
app.MapRendezvousContractEndpoints();
app.MapGet(
"/health/live",
static () => Results.Ok(new HealthResponse { Status = "live" }))
.Produces<HealthResponse>()
.WithName("GetLiveness")
.WithTags("Health");
app.MapGet(
"/health/ready",
static (UdpMediatorService mediator) => mediator.LocalEndpoint is null
? Results.StatusCode(StatusCodes.Status503ServiceUnavailable)
: Results.Ok(new HealthResponse { Status = "ready" }))
.Produces<HealthResponse>()
.Produces(StatusCodes.Status503ServiceUnavailable)
.WithName("GetReadiness")
.WithTags("Health");
await app.RunAsync();
/// <summary>
/// Entry point marker used by integration-test hosts.
/// </summary>
public partial class Program;
@@ -0,0 +1,26 @@
using System.ComponentModel.DataAnnotations;
namespace FinalFactory.Rendezvous.Server.Transport;
/// <summary>
/// Configures the UDP endpoint reserved for the Rendezvous mediator.
/// </summary>
public sealed class UdpMediatorOptions
{
/// <summary>
/// Configuration section containing UDP mediator settings.
/// </summary>
public const string SectionName = "Rendezvous:Udp";
/// <summary>
/// Gets or sets the numeric IP address to bind.
/// </summary>
[Required]
public string ListenAddress { get; set; } = "0.0.0.0";
/// <summary>
/// Gets or sets the UDP port. Zero requests an ephemeral port for tests.
/// </summary>
[Range(0, 65_535)]
public int Port { get; set; } = 9050;
}
@@ -0,0 +1,116 @@
using System.Net;
using System.Net.Sockets;
using Microsoft.Extensions.Options;
namespace FinalFactory.Rendezvous.Server.Transport;
/// <summary>
/// Owns the cancellable UDP socket used by the future NAT mediator.
/// </summary>
public sealed partial class UdpMediatorService : BackgroundService
{
private readonly ILogger<UdpMediatorService> _logger;
private readonly UdpMediatorOptions _options;
private UdpClient? _udpClient;
/// <summary>
/// Initializes a new UDP mediator service.
/// </summary>
public UdpMediatorService(
IOptions<UdpMediatorOptions> options,
ILogger<UdpMediatorService> logger)
{
_options = options.Value;
_logger = logger;
}
/// <summary>
/// Gets the bound endpoint after startup completes.
/// </summary>
public IPEndPoint? LocalEndpoint { get; private set; }
/// <inheritdoc />
public override Task StartAsync(CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
if (_udpClient is not null)
{
throw new InvalidOperationException("The UDP mediator is already running.");
}
IPAddress listenAddress = IPAddress.Parse(_options.ListenAddress);
UdpClient udpClient = new(new IPEndPoint(listenAddress, _options.Port));
_udpClient = udpClient;
IPEndPoint localEndpoint =
(IPEndPoint?)udpClient.Client.LocalEndPoint
?? throw new InvalidOperationException("The UDP socket did not expose its bound endpoint.");
LocalEndpoint = localEndpoint;
LogMediatorListening(_logger, localEndpoint.Address, localEndpoint.Port);
return base.StartAsync(cancellationToken);
}
/// <inheritdoc />
public override async Task StopAsync(CancellationToken cancellationToken)
{
await base.StopAsync(cancellationToken).ConfigureAwait(false);
_udpClient?.Dispose();
_udpClient = null;
LocalEndpoint = null;
LogMediatorStopped(_logger);
}
/// <inheritdoc />
public override void Dispose()
{
_udpClient?.Dispose();
_udpClient = null;
LocalEndpoint = null;
base.Dispose();
}
/// <inheritdoc />
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
UdpClient udpClient = _udpClient
?? throw new InvalidOperationException("The UDP mediator socket was not initialized.");
try
{
while (!stoppingToken.IsCancellationRequested)
{
_ = await udpClient.ReceiveAsync(stoppingToken).ConfigureAwait(false);
// Bootstrap deliberately emits no UDP response. Protocol handling lands in #11.
}
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
// Expected during normal shutdown.
}
catch (ObjectDisposedException) when (stoppingToken.IsCancellationRequested)
{
// Disposing the socket is the fallback that releases a blocked receive.
}
finally
{
LocalEndpoint = null;
}
}
[LoggerMessage(
EventId = 1,
Level = LogLevel.Information,
Message = "UDP mediator listening on {ListenAddress}:{ListenPort}")]
private static partial void LogMediatorListening(
ILogger logger,
IPAddress listenAddress,
int listenPort);
[LoggerMessage(
EventId = 2,
Level = LogLevel.Information,
Message = "UDP mediator stopped")]
private static partial void LogMediatorStopped(ILogger logger);
}
@@ -0,0 +1,15 @@
{
"Rendezvous": {
"Udp": {
"ListenAddress": "0.0.0.0",
"Port": 9050
}
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}
@@ -0,0 +1,37 @@
{
"version": 2,
"dependencies": {
"net10.0": {
"LiteNetLib": {
"type": "Direct",
"requested": "[2.1.4, )",
"resolved": "2.1.4",
"contentHash": "KWlxvMw3Urpqj9joD96LRiK+LC62pQNs/zkXRJc+rHnxgkGp+vV703xzDrxRmv+V1YhCFfIGzs5nrVWtREIlyA=="
},
"Microsoft.AspNetCore.OpenApi": {
"type": "Direct",
"requested": "[10.0.9, )",
"resolved": "10.0.9",
"contentHash": "1ihb8FO9cGgEK1/m3CTtT/SfnynwmiZib0W2pcDVj3KSWk/Sca4VOXEtaptKQc582zpFrzTFiwkGRCglt6H+WQ==",
"dependencies": {
"Microsoft.OpenApi": "2.0.0"
}
},
"Microsoft.Extensions.ApiDescription.Server": {
"type": "Direct",
"requested": "[10.0.9, )",
"resolved": "10.0.9",
"contentHash": "n1m7EAbCbHMGiTy++F+mLSan4MrZe0t00XEpJrkai6BFpB6lwEcirflI9FiMm4G4u55h/2RnWToYBDwS+MnN6g=="
},
"finalfactory.rendezvous.contracts": {
"type": "Project"
},
"Microsoft.OpenApi": {
"type": "CentralTransitive",
"requested": "[2.7.5, )",
"resolved": "2.7.5",
"contentHash": "0FA67RSnRM4tcBKqiqVu/HPdZ9+QOKbmeRjxRUGTCjPU4C0bmUhd97Dso7Yild5P7nOV6GxJ2xrK0Kv/O9xp0w=="
}
}
}
}
@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<AssemblyName>FinalFactory.Rendezvous.TestClient</AssemblyName>
<RootNamespace>FinalFactory.Rendezvous.TestClient</RootNamespace>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../FinalFactory.Rendezvous.Client/FinalFactory.Rendezvous.Client.csproj" />
<ProjectReference Include="../FinalFactory.Rendezvous.Contracts/FinalFactory.Rendezvous.Contracts.csproj" />
<PackageReference Include="LiteNetLib" />
</ItemGroup>
</Project>
@@ -0,0 +1,16 @@
namespace FinalFactory.Rendezvous.TestClient;
/// <summary>
/// Bootstrap entry point for the public-SDK-only diagnostic client.
/// </summary>
public static class Program
{
/// <summary>
/// Runs the bootstrap diagnostic.
/// </summary>
public static int Main()
{
Console.WriteLine("Rendezvous TestClient bootstrap is ready.");
return 0;
}
}
@@ -0,0 +1,46 @@
{
"version": 2,
"dependencies": {
"net8.0": {
"LiteNetLib": {
"type": "Direct",
"requested": "[2.1.4, )",
"resolved": "2.1.4",
"contentHash": "KWlxvMw3Urpqj9joD96LRiK+LC62pQNs/zkXRJc+rHnxgkGp+vV703xzDrxRmv+V1YhCFfIGzs5nrVWtREIlyA=="
},
"System.IO.Pipelines": {
"type": "Transitive",
"resolved": "10.0.10",
"contentHash": "7WX0W96y3dpQdYG4sEGdh38g3/0lOD4/dKbn2rRVOVzKhzoZUn2gKNIKaFeKWs8RCbpFfmmEWsRhSy95hMpvqA=="
},
"System.Text.Encodings.Web": {
"type": "Transitive",
"resolved": "10.0.10",
"contentHash": "o16m2YpDN/pjHsnxf9pTGwkpcuvjW8v1/wGUwJtM1c3QZUKm7ZEO/eYRJg7iIx6GxS2Zv9lAMHpiQwHDdgqauA=="
},
"finalfactory.rendezvous.client": {
"type": "Project",
"dependencies": {
"FinalFactory.Rendezvous.Contracts": "[1.0.0, )",
"LiteNetLib": "[2.1.4, )"
}
},
"finalfactory.rendezvous.contracts": {
"type": "Project",
"dependencies": {
"System.Text.Json": "[10.0.10, )"
}
},
"System.Text.Json": {
"type": "CentralTransitive",
"requested": "[10.0.10, )",
"resolved": "10.0.10",
"contentHash": "bmsO6UdYtBdtn32zYXfsh7KlyTIzV/3V9hdT9RIb4pXKgYOsNxXR+VbWigNwBtNFVGYGm6Hwmqw5a+/IWFd36Q==",
"dependencies": {
"System.IO.Pipelines": "10.0.10",
"System.Text.Encodings.Web": "10.0.10"
}
}
}
}
}
@@ -0,0 +1,117 @@
using System.Reflection;
using System.Xml.Linq;
namespace FinalFactory.Rendezvous.Tests.Architecture;
public sealed class DependencyBoundaryTests
{
[Fact]
public void ContractsAreTransportAndEngineIndependent()
{
string[] references = GetReferences("FinalFactory.Rendezvous.Contracts");
Assert.DoesNotContain(references, IsGodotAssembly);
Assert.DoesNotContain(references, IsServerAssembly);
Assert.DoesNotContain(references, IsLiteNetLibAssembly);
}
[Fact]
public void ClientIsGodotAndServerIndependent()
{
string[] references = GetReferences("FinalFactory.Rendezvous.Client");
Assert.DoesNotContain(references, IsGodotAssembly);
Assert.DoesNotContain(references, IsServerAssembly);
}
[Fact]
public void TestClientUsesOnlyPublicRendezvousDependencies()
{
string[] references = GetReferences("FinalFactory.Rendezvous.TestClient");
Assert.DoesNotContain(references, IsGodotAssembly);
Assert.DoesNotContain(references, IsServerAssembly);
}
[Fact]
public void DeclaredDependencyGraphMatchesTheArchitecture()
{
AssertDeclaredDependencies(
"src/FinalFactory.Rendezvous.Contracts/FinalFactory.Rendezvous.Contracts.csproj",
[],
["System.Text.Json"]);
AssertDeclaredDependencies(
"src/FinalFactory.Rendezvous.Client/FinalFactory.Rendezvous.Client.csproj",
["FinalFactory.Rendezvous.Contracts"],
["LiteNetLib"]);
AssertDeclaredDependencies(
"src/FinalFactory.Rendezvous.Server/FinalFactory.Rendezvous.Server.csproj",
["FinalFactory.Rendezvous.Contracts"],
[
"LiteNetLib",
"Microsoft.AspNetCore.OpenApi",
"Microsoft.Extensions.ApiDescription.Server",
]);
AssertDeclaredDependencies(
"src/FinalFactory.Rendezvous.TestClient/FinalFactory.Rendezvous.TestClient.csproj",
["FinalFactory.Rendezvous.Client", "FinalFactory.Rendezvous.Contracts"],
["LiteNetLib"]);
}
private static void AssertDeclaredDependencies(
string projectPath,
string[] expectedProjects,
string[] expectedPackages)
{
XDocument project = XDocument.Load(Path.Combine(FindRepositoryRoot(), projectPath));
string[] projects = project
.Descendants("ProjectReference")
.Select(static element =>
Path.GetFileNameWithoutExtension(element.Attribute("Include")?.Value) ?? string.Empty)
.Order(StringComparer.Ordinal)
.ToArray();
string[] packages = project
.Descendants("PackageReference")
.Select(static element => element.Attribute("Include")?.Value ?? string.Empty)
.Order(StringComparer.Ordinal)
.ToArray();
Assert.Equal(expectedProjects.Order(StringComparer.Ordinal), projects);
Assert.Equal(expectedPackages.Order(StringComparer.Ordinal), packages);
}
private static string FindRepositoryRoot()
{
DirectoryInfo? directory = new(AppContext.BaseDirectory);
while (directory is not null)
{
if (File.Exists(Path.Combine(directory.FullName, "Rendezvous.slnx")))
{
return directory.FullName;
}
directory = directory.Parent;
}
throw new DirectoryNotFoundException("Could not locate the Rendezvous repository root.");
}
private static string[] GetReferences(string assemblyName) => Assembly
.Load(assemblyName)
.GetReferencedAssemblies()
.Select(static reference => reference.Name ?? string.Empty)
.ToArray();
private static bool IsGodotAssembly(string assemblyName) =>
assemblyName.StartsWith("Godot", StringComparison.OrdinalIgnoreCase);
private static bool IsLiteNetLibAssembly(string assemblyName) =>
string.Equals(assemblyName, "LiteNetLib", StringComparison.Ordinal);
private static bool IsServerAssembly(string assemblyName) =>
string.Equals(
assemblyName,
"FinalFactory.Rendezvous.Server",
StringComparison.Ordinal);
}
@@ -0,0 +1,140 @@
using FinalFactory.Rendezvous.Contracts;
namespace FinalFactory.Rendezvous.Tests.Contracts;
public sealed class ContractLimitTests
{
[Fact]
public void ByteAndCollectionLimitsAcceptTheBoundaryOnly()
{
Assert.True(ContractValidation.IsHttpRequestSizeValid(ContractLimits.HttpRequestMaxBytes));
Assert.False(ContractValidation.IsHttpRequestSizeValid(ContractLimits.HttpRequestMaxBytes + 1));
Assert.True(ContractValidation.IsBrowserResponseSizeValid(ContractLimits.BrowserResponseMaxBytes));
Assert.False(ContractValidation.IsBrowserResponseSizeValid(ContractLimits.BrowserResponseMaxBytes + 1));
Assert.True(ContractValidation.IsPageSizeValid(ContractLimits.BrowserPageMaxItems));
Assert.False(ContractValidation.IsPageSizeValid(ContractLimits.BrowserPageMaxItems + 1));
Assert.False(ContractValidation.IsPageSizeValid(0));
}
[Fact]
public void Utf8LimitsCountBytesInsteadOfCharacters()
{
string atLimit = new('é', ContractLimits.DisplayNameMaxBytes / 2);
string overLimit = atLimit + "é";
Assert.True(ContractValidation.IsDisplayNameValid(atLimit));
Assert.False(ContractValidation.IsDisplayNameValid(overLimit));
Assert.True(ContractValidation.IsBuildVersionValid(
new string('a', ContractLimits.BuildVersionMaxBytes)));
Assert.False(ContractValidation.IsBuildVersionValid(
new string('a', ContractLimits.BuildVersionMaxBytes + 1)));
}
[Fact]
public void CapabilityLimitStaysBelowLiteNetLibTokenLimit()
{
Assert.True(ContractValidation.IsCapabilityValid(
new string('a', ContractLimits.UdpCapabilityMaxCharacters - 1)));
Assert.True(ContractValidation.IsCapabilityValid(
new string('a', ContractLimits.UdpCapabilityMaxCharacters)));
Assert.False(ContractValidation.IsCapabilityValid(
new string('a', ContractLimits.UdpCapabilityMaxCharacters + 1)));
Assert.False(ContractValidation.IsCapabilityValid("not+base64url"));
Assert.True(
ContractLimits.UdpCapabilityMaxCharacters
< ContractLimits.LiteNetLibNatTokenMaxCharacters);
}
[Fact]
public void CapacityAndMetadataLimitsAreBounded()
{
Assert.True(ContractValidation.IsCapacityValid(new SessionCapacity
{
CurrentPlayers = ContractLimits.SessionCapacityMaxPlayers,
MaximumPlayers = ContractLimits.SessionCapacityMaxPlayers,
}));
Assert.False(ContractValidation.IsCapacityValid(new SessionCapacity
{
CurrentPlayers = 0,
MaximumPlayers = ContractLimits.SessionCapacityMaxPlayers + 1,
}));
Dictionary<string, string> maximumKeys = Enumerable
.Range(0, ContractLimits.MetadataMaxKeys)
.ToDictionary(index => $"key-{index}", _ => "value", StringComparer.Ordinal);
Dictionary<string, string> tooManyKeys = new(maximumKeys, StringComparer.Ordinal)
{
["overflow"] = "value",
};
Assert.True(ContractValidation.IsMetadataValid(maximumKeys));
Assert.False(ContractValidation.IsMetadataValid(tooManyKeys));
Assert.False(ContractValidation.IsMetadataValid(new Dictionary<string, string>
{
[new string('k', ContractLimits.MetadataKeyMaxBytes + 1)] = "value",
}));
Assert.False(ContractValidation.IsMetadataValid(new Dictionary<string, string>
{
["key"] = new string('v', ContractLimits.MetadataValueMaxBytes + 1),
}));
}
[Fact]
public void MetadataDocumentLimitAcceptsExactlyFourKibibytes()
{
Dictionary<string, string> atLimit = Enumerable
.Range(0, ContractLimits.MetadataMaxKeys)
.ToDictionary(index => $"k{index:00}", _ => string.Empty, StringComparer.Ordinal);
for (int index = 0; index < 14; index++)
{
atLimit[$"k{index:00}"] = new string('v', ContractLimits.MetadataValueMaxBytes);
}
atLimit["k14"] = new string('v', 223);
Dictionary<string, string> overLimit = new(atLimit, StringComparer.Ordinal)
{
["k14"] = new string('v', 224),
};
Assert.True(ContractValidation.IsMetadataValid(atLimit));
Assert.False(ContractValidation.IsMetadataValid(overLimit));
}
[Fact]
public void EndpointValidationIsAddressFamilyAware()
{
Assert.True(ContractValidation.IsNetworkEndpointValid(new NetworkEndpoint
{
AddressFamily = AddressFamilyKind.Ipv4,
Address = "192.0.2.10",
Port = 9050,
}));
Assert.True(ContractValidation.IsNetworkEndpointValid(new NetworkEndpoint
{
AddressFamily = AddressFamilyKind.Ipv6,
Address = "2001:db8::10",
Port = 9050,
}));
Assert.False(ContractValidation.IsNetworkEndpointValid(new NetworkEndpoint
{
AddressFamily = AddressFamilyKind.Ipv4,
Address = "2001:db8::10",
Port = 9050,
}));
}
[Fact]
public void ScopeAndOpaqueTextBoundariesAreEnforced()
{
Assert.True(GameId.TryParse(new string('a', ContractLimits.GameIdMaxCharacters), out _));
Assert.False(GameId.TryParse(
new string('a', ContractLimits.GameIdMaxCharacters + 1),
out _));
Assert.True(ContractValidation.IsIdempotencyKeyValid(
new string('i', ContractLimits.IdempotencyKeyMaxCharacters)));
Assert.False(ContractValidation.IsIdempotencyKeyValid(
new string('i', ContractLimits.IdempotencyKeyMaxCharacters + 1)));
Assert.True(ContractValidation.IsCursorValid(null));
Assert.False(ContractValidation.IsCursorValid("contains whitespace"));
}
}
@@ -0,0 +1,104 @@
using System.Text.Json;
using FinalFactory.Rendezvous.Contracts;
namespace FinalFactory.Rendezvous.Tests.Contracts;
public sealed class ContractSerializationTests
{
public static TheoryData<string, Type> GoldenJsonVectors => new()
{
{ "register-session.json", typeof(RegisterSessionRequest) },
{ "browse-sessions.json", typeof(BrowseSessionsResponse) },
{ "create-join-response.json", typeof(CreateJoinAttemptResponse) },
{ "api-error.json", typeof(ApiError) },
};
[Theory]
[MemberData(nameof(GoldenJsonVectors))]
public void CanonicalJsonRoundtripsGoldenVectors(string fileName, Type contractType)
{
string expected = ContractTestFiles.Read(fileName);
object? value = JsonSerializer.Deserialize(expected, contractType, ContractJson.Options);
Assert.NotNull(value);
Assert.Equal(expected, JsonSerializer.Serialize(value, contractType, ContractJson.Options));
}
[Fact]
public void IdentifiersAreSerializedAsStrings()
{
SessionListingId id = new(new Guid("00112233-4455-6677-8899-aabbccddeeff"));
Assert.Equal(
"\"00112233-4455-6677-8899-aabbccddeeff\"",
JsonSerializer.Serialize(id, ContractJson.Options));
Assert.Equal(id, JsonSerializer.Deserialize<SessionListingId>(
"\"00112233-4455-6677-8899-aabbccddeeff\"",
ContractJson.Options));
}
[Fact]
public void UnknownObjectFieldsAreIgnoredForAdditiveV1Changes()
{
const string json = """
{"contractVersion":1,"idempotencyKey":"join-001","gameId":"space-game","environmentId":"production","listingId":"00112233-4455-6677-8899-aabbccddeeff","protocolVersion":7,"futureField":{"nested":true}}
""";
CreateJoinAttemptRequest? request = JsonSerializer.Deserialize<CreateJoinAttemptRequest>(
json,
ContractJson.Options);
Assert.NotNull(request);
Assert.Equal(new GameId("space-game"), request.GameId);
Assert.Equal(7u, request.ProtocolVersion);
}
[Fact]
public void MissingNormativeFieldsAreRejectedInsteadOfDefaulted()
{
const string missingVersion = """
{"idempotencyKey":"join-001","gameId":"space-game","environmentId":"production","listingId":"00112233-4455-6677-8899-aabbccddeeff","protocolVersion":7}
""";
Assert.Throws<JsonException>(() => JsonSerializer.Deserialize<CreateJoinAttemptRequest>(
missingVersion,
ContractJson.Options));
}
[Fact]
public void UnknownEnumNamesAndNumericValuesAreRejected()
{
Assert.Throws<JsonException>(() => JsonSerializer.Deserialize<ReportConnectionOutcomeRequest>(
"{\"contractVersion\":1,\"outcome\":\"futureOutcome\",\"elapsedMilliseconds\":1}",
ContractJson.Options));
Assert.Throws<JsonException>(() => JsonSerializer.Deserialize<ReportConnectionOutcomeRequest>(
"{\"contractVersion\":1,\"outcome\":99,\"elapsedMilliseconds\":1}",
ContractJson.Options));
}
[Theory]
[InlineData(0)]
[InlineData(2)]
[InlineData(int.MaxValue)]
public void UnknownContractVersionsFailPredictably(int version)
{
Assert.Equal(
RendezvousErrorCode.UnsupportedContractVersion,
ContractValidation.ValidateContractVersion(version));
}
[Fact]
public void GameplayProtocolCompatibilityIsExactAndBuildIndependent()
{
Assert.True(ContractValidation.AreProtocolsCompatible(7, 7));
Assert.False(ContractValidation.AreProtocolsCompatible(7, 8));
}
[Fact]
public void SharedCanonicalOptionsCannotBeMutatedByConsumers()
{
Assert.True(ContractJson.Options.IsReadOnly);
Assert.Throws<InvalidOperationException>(() =>
ContractJson.Options.WriteIndented = true);
}
}
@@ -0,0 +1,30 @@
namespace FinalFactory.Rendezvous.Tests.Contracts;
internal static class ContractTestFiles
{
public static string Read(string fileName) => File
.ReadAllText(Path.Combine(Directory, fileName))
.TrimEnd('\r', '\n');
public static string Directory
{
get
{
DirectoryInfo? directory = new(AppContext.BaseDirectory);
while (directory is not null)
{
string solution = Path.Combine(directory.FullName, "Rendezvous.slnx");
if (File.Exists(solution))
{
return Path.Combine(
directory.FullName,
"tests/FinalFactory.Rendezvous.Tests/TestData/Contracts/v1");
}
directory = directory.Parent;
}
throw new DirectoryNotFoundException("Could not locate contract test data.");
}
}
}
@@ -0,0 +1,68 @@
using System.Text.Json;
namespace FinalFactory.Rendezvous.Tests.Contracts;
public sealed class OpenApiCompatibilityTests
{
private static readonly string[] ExpectedPaths =
[
"/health/live",
"/health/ready",
"/v1/join-attempts",
"/v1/join-attempts/{attemptId}/outcome",
"/v1/sessions",
"/v1/sessions/{listingId}",
"/v1/sessions/{listingId}/join-attempts",
"/v1/sessions/{listingId}/renew",
];
private static readonly string[] ExpectedListingProperties =
[
"buildVersion",
"capacity",
"contractVersion",
"displayName",
"environmentId",
"gameId",
"listingId",
"metadata",
"protocolVersion",
"publisherTrustMode",
"regionId",
"visibility",
];
[Fact]
public void GeneratedOpenApiContainsTheFrozenV1Surface()
{
string path = Path.Combine(
ContractTestFiles.Directory,
"../../../../../docs/api/rendezvous-v1.json");
using JsonDocument document = JsonDocument.Parse(File.ReadAllText(Path.GetFullPath(path)));
JsonElement root = document.RootElement;
Assert.Equal("3.1.1", root.GetProperty("openapi").GetString());
string[] paths = root.GetProperty("paths")
.EnumerateObject()
.Select(static item => item.Name)
.Order(StringComparer.Ordinal)
.ToArray();
Assert.Equal(ExpectedPaths, paths);
JsonElement schemas = root.GetProperty("components").GetProperty("schemas");
Assert.Equal("string", schemas.GetProperty("GameId").GetProperty("type").GetString());
Assert.Equal("uuid", schemas.GetProperty("SessionListingId").GetProperty("format").GetString());
string[] listingProperties = schemas.GetProperty("SessionListing")
.GetProperty("properties")
.EnumerateObject()
.Select(static item => item.Name)
.Order(StringComparer.Ordinal)
.ToArray();
Assert.Equal(ExpectedListingProperties, listingProperties);
Assert.DoesNotContain(listingProperties, static property =>
property.Contains("token", StringComparison.OrdinalIgnoreCase)
|| property.Contains("endpoint", StringComparison.OrdinalIgnoreCase)
|| property.Contains("playerId", StringComparison.OrdinalIgnoreCase));
}
}
@@ -0,0 +1,103 @@
using System.Reflection;
using FinalFactory.Rendezvous.Contracts;
namespace FinalFactory.Rendezvous.Tests.Contracts;
public sealed class PublicApiCompatibilityTests
{
[Fact]
public void ContractsPublicApiMatchesTheV1Snapshot()
{
string snapshot = CreateSnapshot(typeof(ContractLimits).Assembly);
string expected = ContractTestFiles.Read("contracts-public-api.txt");
if (expected == "SNAPSHOT_PENDING"
&& Environment.GetEnvironmentVariable("RENDEZVOUS_UPDATE_CONTRACT_SNAPSHOT") == "1")
{
string snapshotPath = Path.Combine(
ContractTestFiles.Directory,
"contracts-public-api.txt");
File.WriteAllText(snapshotPath, snapshot + Environment.NewLine);
expected = snapshot;
}
Assert.Equal(expected, snapshot);
}
private static string CreateSnapshot(Assembly assembly)
{
List<string> lines = [];
foreach (Type type in assembly.GetExportedTypes().OrderBy(static type => type.FullName, StringComparer.Ordinal))
{
lines.Add($"TYPE {FormatType(type)}");
if (type.IsEnum)
{
foreach (string name in Enum.GetNames(type))
{
object value = Enum.Parse(type, name);
lines.Add($" ENUM {name}={Convert.ToInt64(value, System.Globalization.CultureInfo.InvariantCulture)}");
}
continue;
}
foreach (FieldInfo field in type.GetFields(BindingFlags.Public | BindingFlags.Static | BindingFlags.DeclaredOnly)
.OrderBy(static field => field.Name, StringComparer.Ordinal))
{
string value = field.IsLiteral
? Convert.ToString(field.GetRawConstantValue(), System.Globalization.CultureInfo.InvariantCulture) ?? "null"
: "non-literal";
lines.Add($" FIELD {FormatType(field.FieldType)} {field.Name}={value}");
}
foreach (ConstructorInfo constructor in type.GetConstructors(BindingFlags.Public | BindingFlags.Instance)
.OrderBy(static constructor => FormatParameters(constructor.GetParameters()), StringComparer.Ordinal))
{
lines.Add($" CTOR ({FormatParameters(constructor.GetParameters())})");
}
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)}";
lines.Add($" PROP {FormatType(property.PropertyType)} {property.Name} {{{accessors}}}");
}
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)
.ThenBy(static method => FormatParameters(method.GetParameters()), StringComparer.Ordinal))
{
lines.Add($" METHOD {FormatType(method.ReturnType)} {method.Name}({FormatParameters(method.GetParameters())})");
}
}
return string.Join('\n', lines);
}
private static string FormatParameters(ParameterInfo[] parameters) => string.Join(
", ",
parameters.Select(static parameter =>
$"{FormatType(parameter.ParameterType)} {parameter.Name}"));
private static string FormatType(Type type)
{
if (type.IsByRef)
{
return $"{FormatType(type.GetElementType()!)}&";
}
if (type.IsArray)
{
return $"{FormatType(type.GetElementType()!)}[]";
}
if (type.IsGenericType)
{
string name = type.GetGenericTypeDefinition().FullName!;
name = name[..name.IndexOf('`')];
return $"{name}<{string.Join(",", type.GetGenericArguments().Select(FormatType))}>";
}
return type.FullName ?? type.Name;
}
}
@@ -0,0 +1,101 @@
using FinalFactory.Rendezvous.Contracts;
namespace FinalFactory.Rendezvous.Tests.Contracts;
public sealed class UdpCodecTests
{
private static readonly Guid Handle = new("00112233-4455-6677-8899-aabbccddeeff");
[Fact]
public void HostPresenceMatchesTheV1GoldenVector()
{
byte[] encoded = RendezvousUdpCodec.Encode(CreateDatagram());
Assert.Equal(ContractTestFiles.Read("udp-host-ipv4.hex"), Convert.ToHexString(encoded).ToLowerInvariant());
Assert.True(RendezvousUdpCodec.TryDecode(encoded, out PresenceDatagram? decoded, out UdpDecodeError error));
Assert.Equal(UdpDecodeError.None, error);
Assert.NotNull(decoded);
Assert.Equal(Handle, decoded.MediationHandle.Value);
Assert.Equal("192.0.2.10", decoded.LocalAddress);
Assert.Equal(9050, decoded.LocalPort);
Assert.Equal("Abc_123-xYz", decoded.Capability);
}
[Fact]
public void Ipv6RoundtripsWithoutLosingItsAddressFamily()
{
PresenceDatagram original = CreateDatagram();
original.MessageType = UdpPresenceMessageType.ClientPresence;
original.AddressFamily = AddressFamilyKind.Ipv6;
original.LocalAddress = "2001:db8::10";
byte[] encoded = RendezvousUdpCodec.Encode(original);
Assert.True(RendezvousUdpCodec.TryDecode(encoded, out PresenceDatagram? decoded, out _));
Assert.NotNull(decoded);
Assert.Equal(AddressFamilyKind.Ipv6, decoded.AddressFamily);
Assert.Equal("2001:db8::10", decoded.LocalAddress);
}
[Fact]
public void EncoderRejectsAnAddressFamilyMismatch()
{
PresenceDatagram datagram = CreateDatagram();
datagram.AddressFamily = AddressFamilyKind.Ipv4;
datagram.LocalAddress = "2001:db8::10";
Assert.Throws<ArgumentException>(() => RendezvousUdpCodec.Encode(datagram));
}
[Theory]
[InlineData(2, 2, UdpDecodeError.UnsupportedVersion)]
[InlineData(3, 3, UdpDecodeError.UnknownMessageType)]
[InlineData(4, 1, UdpDecodeError.InvalidFlags)]
[InlineData(21, 5, UdpDecodeError.InvalidAddressFamily)]
public void DecoderReturnsStableErrorsForUnknownHeaderValues(
int offset,
byte replacement,
UdpDecodeError expected)
{
byte[] encoded = RendezvousUdpCodec.Encode(CreateDatagram());
encoded[offset] = replacement;
Assert.False(RendezvousUdpCodec.TryDecode(encoded, out _, out UdpDecodeError actual));
Assert.Equal(expected, actual);
}
[Fact]
public void DecoderRejectsTruncationTrailingDataAndOversizedPackets()
{
byte[] encoded = RendezvousUdpCodec.Encode(CreateDatagram());
byte[] trailing = [.. encoded, 0];
byte[] oversized = new byte[ContractLimits.UdpDatagramMaxBytes + 1];
Assert.False(RendezvousUdpCodec.TryDecode(encoded.AsSpan(0, encoded.Length - 1), out _, out UdpDecodeError truncated));
Assert.Equal(UdpDecodeError.Truncated, truncated);
Assert.False(RendezvousUdpCodec.TryDecode(trailing, out _, out UdpDecodeError trailingError));
Assert.Equal(UdpDecodeError.TrailingData, trailingError);
Assert.False(RendezvousUdpCodec.TryDecode(oversized, out _, out UdpDecodeError oversizedError));
Assert.Equal(UdpDecodeError.DatagramTooLarge, oversizedError);
}
[Fact]
public void DecoderRejectsNonBase64UrlCapabilities()
{
byte[] encoded = RendezvousUdpCodec.Encode(CreateDatagram());
encoded[^1] = (byte)'+';
Assert.False(RendezvousUdpCodec.TryDecode(encoded, out _, out UdpDecodeError error));
Assert.Equal(UdpDecodeError.InvalidCapability, error);
}
private static PresenceDatagram CreateDatagram() => new()
{
MessageType = UdpPresenceMessageType.HostPresence,
MediationHandle = new MediationHandle(Handle),
AddressFamily = AddressFamilyKind.Ipv4,
LocalAddress = "192.0.2.10",
LocalPort = 9050,
Capability = "Abc_123-xYz",
};
}
@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<AssemblyName>FinalFactory.Rendezvous.Tests</AssemblyName>
<RootNamespace>FinalFactory.Rendezvous.Tests</RootNamespace>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../src/FinalFactory.Rendezvous.Client/FinalFactory.Rendezvous.Client.csproj" />
<ProjectReference Include="../../src/FinalFactory.Rendezvous.Contracts/FinalFactory.Rendezvous.Contracts.csproj" />
<ProjectReference Include="../../src/FinalFactory.Rendezvous.Server/FinalFactory.Rendezvous.Server.csproj" />
<ProjectReference Include="../../src/FinalFactory.Rendezvous.TestClient/FinalFactory.Rendezvous.TestClient.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1 @@
global using Xunit;
@@ -0,0 +1,34 @@
using System.Net;
using FinalFactory.Rendezvous.Server.Transport;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
namespace FinalFactory.Rendezvous.Tests.Server;
public sealed class UdpMediatorServiceTests
{
[Fact]
public async Task ServiceBindsAnEphemeralUdpPortAndStopsCleanly()
{
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(5));
UdpMediatorOptions options = new()
{
ListenAddress = IPAddress.Loopback.ToString(),
Port = 0,
};
using UdpMediatorService service = new(
Options.Create(options),
NullLogger<UdpMediatorService>.Instance);
await service.StartAsync(timeout.Token);
IPEndPoint? boundEndpoint = service.LocalEndpoint;
Assert.NotNull(boundEndpoint);
Assert.Equal(IPAddress.Loopback, boundEndpoint.Address);
Assert.InRange(boundEndpoint.Port, 1, 65_535);
await service.StopAsync(timeout.Token);
Assert.Null(service.LocalEndpoint);
}
}
@@ -0,0 +1 @@
{"contractVersion":1,"code":"rateLimited","message":"Try again later.","correlationId":"request-001","retryAfterSeconds":3}
@@ -0,0 +1 @@
{"contractVersion":1,"items":[{"contractVersion":1,"listingId":"00112233-4455-6677-8899-aabbccddeeff","gameId":"space-game","environmentId":"production","regionId":"eu-central","protocolVersion":7,"buildVersion":"1.4.2","displayName":"Europa Relay","visibility":"public","publisherTrustMode":"managedDedicated","capacity":{"currentPlayers":2,"maximumPlayers":8},"metadata":{"mode":"co-op","map":"europa"}}],"nextCursor":"cursor-002"}
@@ -0,0 +1,315 @@
TYPE FinalFactory.Rendezvous.Contracts.AddressFamilyKind
ENUM Ipv4=4
ENUM Ipv6=6
TYPE FinalFactory.Rendezvous.Contracts.ApiError
CTOR ()
PROP FinalFactory.Rendezvous.Contracts.RendezvousErrorCode Code {get;set;}
PROP System.Int32 ContractVersion {get;set;}
PROP System.String CorrelationId {get;set;}
PROP System.String Message {get;set;}
PROP System.Nullable<System.Int32> RetryAfterSeconds {get;set;}
TYPE FinalFactory.Rendezvous.Contracts.BrowseHostJoinAttemptsResponse
CTOR ()
PROP System.Int32 ContractVersion {get;set;}
PROP System.Collections.Generic.List<FinalFactory.Rendezvous.Contracts.HostJoinAttempt> Items {get;set;}
PROP System.String NextCursor {get;set;}
TYPE FinalFactory.Rendezvous.Contracts.BrowseSessionsRequest
CTOR ()
PROP System.Int32 ContractVersion {get;set;}
PROP System.String Cursor {get;set;}
PROP FinalFactory.Rendezvous.Contracts.EnvironmentId EnvironmentId {get;set;}
PROP FinalFactory.Rendezvous.Contracts.GameId GameId {get;set;}
PROP System.Int32 PageSize {get;set;}
PROP System.UInt32 ProtocolVersion {get;set;}
PROP System.Nullable<FinalFactory.Rendezvous.Contracts.RegionId> RegionId {get;set;}
TYPE FinalFactory.Rendezvous.Contracts.BrowseSessionsResponse
CTOR ()
PROP System.Int32 ContractVersion {get;set;}
PROP System.Collections.Generic.List<FinalFactory.Rendezvous.Contracts.SessionListing> Items {get;set;}
PROP System.String NextCursor {get;set;}
TYPE FinalFactory.Rendezvous.Contracts.ConnectionOutcomeKind
ENUM Connected=1
ENUM Cancelled=2
ENUM TimedOut=3
ENUM IncompatibleProtocol=4
ENUM StaleHost=5
ENUM ServiceRejected=6
ENUM HostRejected=7
ENUM TransportFailed=8
ENUM FallbackOffered=9
TYPE FinalFactory.Rendezvous.Contracts.ContractJson
PROP System.Text.Json.JsonSerializerOptions Options {get;}
METHOD System.Void Configure(System.Text.Json.JsonSerializerOptions options)
METHOD System.Text.Json.JsonSerializerOptions CreateOptions()
TYPE FinalFactory.Rendezvous.Contracts.ContractLimits
FIELD System.Int32 BrowserPageMaxItems=100
FIELD System.Int32 BrowserResponseMaxBytes=262144
FIELD System.Int32 BuildVersionMaxBytes=64
FIELD System.Int32 ConnectionTicketMaxCharacters=192
FIELD System.Int32 ContractVersion=1
FIELD System.Int32 CursorMaxCharacters=512
FIELD System.Int32 DiagnosticCodeMaxCharacters=64
FIELD System.Int32 DisplayNameMaxBytes=128
FIELD System.Int32 EnvironmentIdMaxCharacters=32
FIELD System.Int32 ErrorMessageMaxBytes=256
FIELD System.Int32 GameIdMaxCharacters=64
FIELD System.Int32 HttpRequestMaxBytes=16384
FIELD System.Int32 IdempotencyKeyMaxCharacters=64
FIELD System.Int32 LiteNetLibNatTokenMaxCharacters=256
FIELD System.Int32 MetadataKeyMaxBytes=64
FIELD System.Int32 MetadataMaxBytes=4096
FIELD System.Int32 MetadataMaxKeys=32
FIELD System.Int32 MetadataValueMaxBytes=256
FIELD System.Int32 OpaqueHttpCredentialMaxCharacters=1024
FIELD System.Int32 RegionIdMaxCharacters=32
FIELD System.Int32 SessionCapacityMaxPlayers=10000
FIELD System.Int32 UdpCapabilityMaxCharacters=192
FIELD System.Int32 UdpDatagramMaxBytes=1200
TYPE FinalFactory.Rendezvous.Contracts.ContractValidation
METHOD System.Boolean AreProtocolsCompatible(System.UInt32 requested, System.UInt32 offered)
METHOD System.Boolean IsBrowserResponseSizeValid(System.Int32 byteCount)
METHOD System.Boolean IsBuildVersionValid(System.String value)
METHOD System.Boolean IsCapabilityValid(System.String capability)
METHOD System.Boolean IsCapacityValid(FinalFactory.Rendezvous.Contracts.SessionCapacity capacity)
METHOD System.Boolean IsConnectionTicketValid(System.String ticket)
METHOD System.Boolean IsCursorValid(System.String value)
METHOD System.Boolean IsDiagnosticCodeValid(System.String value)
METHOD System.Boolean IsDisplayNameValid(System.String value)
METHOD System.Boolean IsHttpRequestSizeValid(System.Int32 byteCount)
METHOD System.Boolean IsIdempotencyKeyValid(System.String value)
METHOD System.Boolean IsMetadataValid(System.Collections.Generic.IReadOnlyDictionary<System.String,System.String> metadata)
METHOD System.Boolean IsNetworkEndpointValid(FinalFactory.Rendezvous.Contracts.NetworkEndpoint endpoint)
METHOD System.Boolean IsOpaqueHttpCredentialValid(System.String value)
METHOD System.Boolean IsPageSizeValid(System.Int32 pageSize)
METHOD System.Boolean IsUtf8LengthWithin(System.String value, System.Int32 maximumBytes)
METHOD FinalFactory.Rendezvous.Contracts.RendezvousErrorCode ValidateContractVersion(System.Int32 contractVersion)
TYPE FinalFactory.Rendezvous.Contracts.CreateJoinAttemptRequest
CTOR ()
PROP System.Int32 ContractVersion {get;set;}
PROP FinalFactory.Rendezvous.Contracts.EnvironmentId EnvironmentId {get;set;}
PROP FinalFactory.Rendezvous.Contracts.GameId GameId {get;set;}
PROP System.String IdempotencyKey {get;set;}
PROP FinalFactory.Rendezvous.Contracts.SessionListingId ListingId {get;set;}
PROP System.UInt32 ProtocolVersion {get;set;}
TYPE FinalFactory.Rendezvous.Contracts.CreateJoinAttemptResponse
CTOR ()
PROP FinalFactory.Rendezvous.Contracts.JoinAttemptId AttemptId {get;set;}
PROP System.String ClientPunchCapability {get;set;}
PROP System.Int32 ContractVersion {get;set;}
PROP FinalFactory.Rendezvous.Contracts.NetworkEndpoint DedicatedFallback {get;set;}
PROP System.DateTimeOffset ExpiresAt {get;set;}
PROP FinalFactory.Rendezvous.Contracts.MediationHandle MediationHandle {get;set;}
TYPE FinalFactory.Rendezvous.Contracts.DeleteSessionRequest
CTOR ()
PROP System.Int32 ContractVersion {get;set;}
PROP System.String LeaseToken {get;set;}
TYPE FinalFactory.Rendezvous.Contracts.EnvironmentId
CTOR (System.String value)
PROP System.String Value {get;}
METHOD System.Boolean Equals(FinalFactory.Rendezvous.Contracts.EnvironmentId other)
METHOD System.Boolean Equals(System.Object obj)
METHOD System.Int32 GetHashCode()
METHOD System.String ToString()
METHOD System.Boolean TryParse(System.String value, FinalFactory.Rendezvous.Contracts.EnvironmentId& environmentId)
METHOD System.Boolean op_Equality(FinalFactory.Rendezvous.Contracts.EnvironmentId left, FinalFactory.Rendezvous.Contracts.EnvironmentId right)
METHOD System.Boolean op_Inequality(FinalFactory.Rendezvous.Contracts.EnvironmentId left, FinalFactory.Rendezvous.Contracts.EnvironmentId right)
TYPE FinalFactory.Rendezvous.Contracts.GameId
CTOR (System.String value)
PROP System.String Value {get;}
METHOD System.Boolean Equals(FinalFactory.Rendezvous.Contracts.GameId other)
METHOD System.Boolean Equals(System.Object obj)
METHOD System.Int32 GetHashCode()
METHOD System.String ToString()
METHOD System.Boolean TryParse(System.String value, FinalFactory.Rendezvous.Contracts.GameId& gameId)
METHOD System.Boolean op_Equality(FinalFactory.Rendezvous.Contracts.GameId left, FinalFactory.Rendezvous.Contracts.GameId right)
METHOD System.Boolean op_Inequality(FinalFactory.Rendezvous.Contracts.GameId left, FinalFactory.Rendezvous.Contracts.GameId right)
TYPE FinalFactory.Rendezvous.Contracts.GetSessionResponse
CTOR ()
PROP System.Int32 ContractVersion {get;set;}
PROP FinalFactory.Rendezvous.Contracts.SessionListing Session {get;set;}
TYPE FinalFactory.Rendezvous.Contracts.HealthResponse
CTOR ()
PROP System.Int32 ContractVersion {get;set;}
PROP System.String Status {get;set;}
TYPE FinalFactory.Rendezvous.Contracts.HostJoinAttempt
CTOR ()
PROP FinalFactory.Rendezvous.Contracts.JoinAttemptId AttemptId {get;set;}
PROP System.DateTimeOffset ExpiresAt {get;set;}
PROP System.String HostPunchCapability {get;set;}
PROP FinalFactory.Rendezvous.Contracts.MediationHandle MediationHandle {get;set;}
TYPE FinalFactory.Rendezvous.Contracts.JoinAttemptId
CTOR (System.Guid value)
PROP System.Guid Value {get;}
METHOD System.Boolean Equals(FinalFactory.Rendezvous.Contracts.JoinAttemptId other)
METHOD System.Boolean Equals(System.Object obj)
METHOD System.Int32 GetHashCode()
METHOD System.String ToString()
METHOD System.Boolean TryParse(System.String value, FinalFactory.Rendezvous.Contracts.JoinAttemptId& id)
METHOD System.Boolean op_Equality(FinalFactory.Rendezvous.Contracts.JoinAttemptId left, FinalFactory.Rendezvous.Contracts.JoinAttemptId right)
METHOD System.Boolean op_Inequality(FinalFactory.Rendezvous.Contracts.JoinAttemptId left, FinalFactory.Rendezvous.Contracts.JoinAttemptId right)
TYPE FinalFactory.Rendezvous.Contracts.LeaseId
CTOR (System.Guid value)
PROP System.Guid Value {get;}
METHOD System.Boolean Equals(FinalFactory.Rendezvous.Contracts.LeaseId other)
METHOD System.Boolean Equals(System.Object obj)
METHOD System.Int32 GetHashCode()
METHOD System.String ToString()
METHOD System.Boolean TryParse(System.String value, FinalFactory.Rendezvous.Contracts.LeaseId& id)
METHOD System.Boolean op_Equality(FinalFactory.Rendezvous.Contracts.LeaseId left, FinalFactory.Rendezvous.Contracts.LeaseId right)
METHOD System.Boolean op_Inequality(FinalFactory.Rendezvous.Contracts.LeaseId left, FinalFactory.Rendezvous.Contracts.LeaseId right)
TYPE FinalFactory.Rendezvous.Contracts.ListingVisibility
ENUM Public=1
ENUM Unlisted=2
TYPE FinalFactory.Rendezvous.Contracts.MediationHandle
CTOR (System.Guid value)
PROP System.Guid Value {get;}
METHOD System.Boolean Equals(FinalFactory.Rendezvous.Contracts.MediationHandle other)
METHOD System.Boolean Equals(System.Object obj)
METHOD System.Int32 GetHashCode()
METHOD System.String ToString()
METHOD System.Boolean TryParse(System.String value, FinalFactory.Rendezvous.Contracts.MediationHandle& id)
METHOD System.Boolean op_Equality(FinalFactory.Rendezvous.Contracts.MediationHandle left, FinalFactory.Rendezvous.Contracts.MediationHandle right)
METHOD System.Boolean op_Inequality(FinalFactory.Rendezvous.Contracts.MediationHandle left, FinalFactory.Rendezvous.Contracts.MediationHandle right)
TYPE FinalFactory.Rendezvous.Contracts.NetworkEndpoint
CTOR ()
PROP System.String Address {get;set;}
PROP FinalFactory.Rendezvous.Contracts.AddressFamilyKind AddressFamily {get;set;}
PROP System.Int32 Port {get;set;}
TYPE FinalFactory.Rendezvous.Contracts.PresenceDatagram
CTOR ()
PROP FinalFactory.Rendezvous.Contracts.AddressFamilyKind AddressFamily {get;set;}
PROP System.String Capability {get;set;}
PROP System.Int32 ContractVersion {get;set;}
PROP System.String LocalAddress {get;set;}
PROP System.Int32 LocalPort {get;set;}
PROP FinalFactory.Rendezvous.Contracts.MediationHandle MediationHandle {get;set;}
PROP FinalFactory.Rendezvous.Contracts.UdpPresenceMessageType MessageType {get;set;}
TYPE FinalFactory.Rendezvous.Contracts.PublisherTrustMode
ENUM ManagedDedicated=1
ENUM PlayerGrant=2
ENUM AnonymousUnlisted=3
TYPE FinalFactory.Rendezvous.Contracts.RegionId
CTOR (System.String value)
PROP System.String Value {get;}
METHOD System.Boolean Equals(FinalFactory.Rendezvous.Contracts.RegionId other)
METHOD System.Boolean Equals(System.Object obj)
METHOD System.Int32 GetHashCode()
METHOD System.String ToString()
METHOD System.Boolean TryParse(System.String value, FinalFactory.Rendezvous.Contracts.RegionId& regionId)
METHOD System.Boolean op_Equality(FinalFactory.Rendezvous.Contracts.RegionId left, FinalFactory.Rendezvous.Contracts.RegionId right)
METHOD System.Boolean op_Inequality(FinalFactory.Rendezvous.Contracts.RegionId left, FinalFactory.Rendezvous.Contracts.RegionId right)
TYPE FinalFactory.Rendezvous.Contracts.RegisterSessionRequest
CTOR ()
PROP System.String BuildVersion {get;set;}
PROP FinalFactory.Rendezvous.Contracts.SessionCapacity Capacity {get;set;}
PROP System.Int32 ContractVersion {get;set;}
PROP System.String DisplayName {get;set;}
PROP FinalFactory.Rendezvous.Contracts.EnvironmentId EnvironmentId {get;set;}
PROP FinalFactory.Rendezvous.Contracts.GameId GameId {get;set;}
PROP System.String IdempotencyKey {get;set;}
PROP System.Collections.Generic.Dictionary<System.String,System.String> Metadata {get;set;}
PROP System.UInt32 ProtocolVersion {get;set;}
PROP FinalFactory.Rendezvous.Contracts.RegionId RegionId {get;set;}
PROP FinalFactory.Rendezvous.Contracts.ListingVisibility Visibility {get;set;}
TYPE FinalFactory.Rendezvous.Contracts.RegisterSessionResponse
CTOR ()
PROP System.Int32 ContractVersion {get;set;}
PROP System.DateTimeOffset ExpiresAt {get;set;}
PROP System.String HostPresenceCapability {get;set;}
PROP FinalFactory.Rendezvous.Contracts.MediationHandle HostPresenceHandle {get;set;}
PROP FinalFactory.Rendezvous.Contracts.LeaseId LeaseId {get;set;}
PROP System.String LeaseToken {get;set;}
PROP FinalFactory.Rendezvous.Contracts.SessionListingId ListingId {get;set;}
TYPE FinalFactory.Rendezvous.Contracts.RendezvousErrorCode
ENUM None=0
ENUM InvalidRequest=1
ENUM UnsupportedContractVersion=2
ENUM IncompatibleProtocol=3
ENUM AuthenticationRequired=4
ENUM Forbidden=5
ENUM NotFound=6
ENUM Conflict=7
ENUM RateLimited=8
ENUM StaleHost=9
ENUM Expired=10
ENUM ReplayRejected=11
ENUM CapacityExceeded=12
ENUM ServiceUnavailable=13
ENUM InternalError=14
TYPE FinalFactory.Rendezvous.Contracts.RendezvousUdpCodec
FIELD System.Byte FlagsNone=0
FIELD System.Byte MagicFirst=82
FIELD System.Byte MagicSecond=86
METHOD System.Byte[] Encode(FinalFactory.Rendezvous.Contracts.PresenceDatagram datagram)
METHOD System.Boolean TryDecode(System.ReadOnlySpan<System.Byte> encoded, FinalFactory.Rendezvous.Contracts.PresenceDatagram& datagram, FinalFactory.Rendezvous.Contracts.UdpDecodeError& error)
TYPE FinalFactory.Rendezvous.Contracts.RenewLeaseRequest
CTOR ()
PROP System.Int32 ContractVersion {get;set;}
PROP System.String LeaseToken {get;set;}
TYPE FinalFactory.Rendezvous.Contracts.RenewLeaseResponse
CTOR ()
PROP System.Int32 ContractVersion {get;set;}
PROP System.DateTimeOffset ExpiresAt {get;set;}
TYPE FinalFactory.Rendezvous.Contracts.ReportConnectionOutcomeRequest
CTOR ()
PROP System.Int32 ContractVersion {get;set;}
PROP System.String DiagnosticCode {get;set;}
PROP System.Int32 ElapsedMilliseconds {get;set;}
PROP FinalFactory.Rendezvous.Contracts.ConnectionOutcomeKind Outcome {get;set;}
TYPE FinalFactory.Rendezvous.Contracts.ReportConnectionOutcomeResponse
CTOR ()
PROP System.Boolean Accepted {get;set;}
PROP System.Int32 ContractVersion {get;set;}
TYPE FinalFactory.Rendezvous.Contracts.SessionCapacity
CTOR ()
PROP System.Int32 CurrentPlayers {get;set;}
PROP System.Int32 MaximumPlayers {get;set;}
TYPE FinalFactory.Rendezvous.Contracts.SessionListing
CTOR ()
PROP System.String BuildVersion {get;set;}
PROP FinalFactory.Rendezvous.Contracts.SessionCapacity Capacity {get;set;}
PROP System.Int32 ContractVersion {get;set;}
PROP System.String DisplayName {get;set;}
PROP FinalFactory.Rendezvous.Contracts.EnvironmentId EnvironmentId {get;set;}
PROP FinalFactory.Rendezvous.Contracts.GameId GameId {get;set;}
PROP FinalFactory.Rendezvous.Contracts.SessionListingId ListingId {get;set;}
PROP System.Collections.Generic.Dictionary<System.String,System.String> Metadata {get;set;}
PROP System.UInt32 ProtocolVersion {get;set;}
PROP FinalFactory.Rendezvous.Contracts.PublisherTrustMode PublisherTrustMode {get;set;}
PROP FinalFactory.Rendezvous.Contracts.RegionId RegionId {get;set;}
PROP FinalFactory.Rendezvous.Contracts.ListingVisibility Visibility {get;set;}
TYPE FinalFactory.Rendezvous.Contracts.SessionListingId
CTOR (System.Guid value)
PROP System.Guid Value {get;}
METHOD System.Boolean Equals(FinalFactory.Rendezvous.Contracts.SessionListingId other)
METHOD System.Boolean Equals(System.Object obj)
METHOD System.Int32 GetHashCode()
METHOD System.String ToString()
METHOD System.Boolean TryParse(System.String value, FinalFactory.Rendezvous.Contracts.SessionListingId& id)
METHOD System.Boolean op_Equality(FinalFactory.Rendezvous.Contracts.SessionListingId left, FinalFactory.Rendezvous.Contracts.SessionListingId right)
METHOD System.Boolean op_Inequality(FinalFactory.Rendezvous.Contracts.SessionListingId left, FinalFactory.Rendezvous.Contracts.SessionListingId right)
TYPE FinalFactory.Rendezvous.Contracts.UdpDecodeError
ENUM None=0
ENUM DatagramTooLarge=1
ENUM Truncated=2
ENUM InvalidMagic=3
ENUM UnsupportedVersion=4
ENUM UnknownMessageType=5
ENUM InvalidFlags=6
ENUM InvalidHandle=7
ENUM InvalidAddressFamily=8
ENUM InvalidAddress=9
ENUM InvalidPort=10
ENUM InvalidCapability=11
ENUM TrailingData=12
TYPE FinalFactory.Rendezvous.Contracts.UdpPresenceMessageType
ENUM HostPresence=1
ENUM ClientPresence=2
TYPE FinalFactory.Rendezvous.Contracts.UpdateSessionRequest
CTOR ()
PROP System.String BuildVersion {get;set;}
PROP FinalFactory.Rendezvous.Contracts.SessionCapacity Capacity {get;set;}
PROP System.Int32 ContractVersion {get;set;}
PROP System.String DisplayName {get;set;}
PROP System.String LeaseToken {get;set;}
PROP System.Collections.Generic.Dictionary<System.String,System.String> Metadata {get;set;}
@@ -0,0 +1 @@
{"contractVersion":1,"attemptId":"11112233-4455-6677-8899-aabbccddeeff","mediationHandle":"22222233-4455-6677-8899-aabbccddeeff","clientPunchCapability":"Abc_123-xYz","expiresAt":"2026-07-16T12:00:00+00:00","dedicatedFallback":{"addressFamily":"ipv6","address":"2001:db8::10","port":9050}}
@@ -0,0 +1 @@
{"contractVersion":1,"idempotencyKey":"register-001","gameId":"space-game","environmentId":"production","regionId":"eu-central","protocolVersion":7,"buildVersion":"1.4.2","displayName":"Europa Relay","visibility":"public","capacity":{"currentPlayers":2,"maximumPlayers":8},"metadata":{"mode":"co-op","map":"europa"}}
@@ -0,0 +1 @@
525601010000112233445566778899aabbccddeeff0404c000020a235a0b4162635f3132332d78597a
@@ -0,0 +1,145 @@
{
"version": 2,
"dependencies": {
"net10.0": {
"Microsoft.NET.Test.Sdk": {
"type": "Direct",
"requested": "[18.4.0, )",
"resolved": "18.4.0",
"contentHash": "w49iZdL4HL6V25l41NVQLXWQ+e71GvSkKVteMrOL02gP/PUkcnO/1yEb2s9FntU4wGmJWfKnyrRAhcMHd9ZZNA==",
"dependencies": {
"Microsoft.CodeCoverage": "18.4.0",
"Microsoft.TestPlatform.TestHost": "18.4.0"
}
},
"xunit": {
"type": "Direct",
"requested": "[2.9.3, )",
"resolved": "2.9.3",
"contentHash": "TlXQBinK35LpOPKHAqbLY4xlEen9TBafjs0V5KnA4wZsoQLQJiirCR4CbIXvOH8NzkW4YeJKP5P/Bnrodm0h9Q==",
"dependencies": {
"xunit.analyzers": "1.18.0",
"xunit.assert": "2.9.3",
"xunit.core": "[2.9.3]"
}
},
"xunit.runner.visualstudio": {
"type": "Direct",
"requested": "[3.1.5, )",
"resolved": "3.1.5",
"contentHash": "tKi7dSTwP4m5m9eXPM2Ime4Kn7xNf4x4zT9sdLO/G4hZVnQCRiMTWoSZqI/pYTVeI27oPPqHBKYI/DjJ9GsYgA=="
},
"Microsoft.CodeCoverage": {
"type": "Transitive",
"resolved": "18.4.0",
"contentHash": "9O0BtCfzCWrkAmK187ugKdq72HHOXoOUjuWFDVc2LsZZ0pOnA9bTt+Sg9q4cF+MoAaUU+MuWtvBuFsnduviJow=="
},
"Microsoft.TestPlatform.ObjectModel": {
"type": "Transitive",
"resolved": "18.4.0",
"contentHash": "4L6m2kS2pY5uJ9cpeRxzW22opr6ttScIRqsOpMDQpgENp/ZwxkkQCcmc6LRSURo2dFaaSW5KVflQZvroiJ7Wzg=="
},
"Microsoft.TestPlatform.TestHost": {
"type": "Transitive",
"resolved": "18.4.0",
"contentHash": "gZsCHI+zOmZCcKZieIL4Jg14qKD2OGZOmX5DehuIk1EA9BN6Crm0+taXQNEuajOH1G9CCyBxw8VWR4t5tumcng==",
"dependencies": {
"Microsoft.TestPlatform.ObjectModel": "18.4.0",
"Newtonsoft.Json": "13.0.3"
}
},
"Newtonsoft.Json": {
"type": "Transitive",
"resolved": "13.0.3",
"contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ=="
},
"xunit.abstractions": {
"type": "Transitive",
"resolved": "2.0.3",
"contentHash": "pot1I4YOxlWjIb5jmwvvQNbTrZ3lJQ+jUGkGjWE3hEFM0l5gOnBWS+H3qsex68s5cO52g+44vpGzhAt+42vwKg=="
},
"xunit.analyzers": {
"type": "Transitive",
"resolved": "1.18.0",
"contentHash": "OtFMHN8yqIcYP9wcVIgJrq01AfTxijjAqVDy/WeQVSyrDC1RzBWeQPztL49DN2syXRah8TYnfvk035s7L95EZQ=="
},
"xunit.assert": {
"type": "Transitive",
"resolved": "2.9.3",
"contentHash": "/Kq28fCE7MjOV42YLVRAJzRF0WmEqsmflm0cfpMjGtzQ2lR5mYVj1/i0Y8uDAOLczkL3/jArrwehfMD0YogMAA=="
},
"xunit.core": {
"type": "Transitive",
"resolved": "2.9.3",
"contentHash": "BiAEvqGvyme19wE0wTKdADH+NloYqikiU0mcnmiNyXaF9HyHmE6sr/3DC5vnBkgsWaE6yPyWszKSPSApWdRVeQ==",
"dependencies": {
"xunit.extensibility.core": "[2.9.3]",
"xunit.extensibility.execution": "[2.9.3]"
}
},
"xunit.extensibility.core": {
"type": "Transitive",
"resolved": "2.9.3",
"contentHash": "kf3si0YTn2a8J8eZNb+zFpwfoyvIrQ7ivNk5ZYA5yuYk1bEtMe4DxJ2CF/qsRgmEnDr7MnW1mxylBaHTZ4qErA==",
"dependencies": {
"xunit.abstractions": "2.0.3"
}
},
"xunit.extensibility.execution": {
"type": "Transitive",
"resolved": "2.9.3",
"contentHash": "yMb6vMESlSrE3Wfj7V6cjQ3S4TXdXpRqYeNEI3zsX31uTsGMJjEw6oD5F5u1cHnMptjhEECnmZSsPxB6ChZHDQ==",
"dependencies": {
"xunit.extensibility.core": "[2.9.3]"
}
},
"finalfactory.rendezvous.client": {
"type": "Project",
"dependencies": {
"FinalFactory.Rendezvous.Contracts": "[1.0.0, )",
"LiteNetLib": "[2.1.4, )"
}
},
"finalfactory.rendezvous.contracts": {
"type": "Project"
},
"finalfactory.rendezvous.server": {
"type": "Project",
"dependencies": {
"FinalFactory.Rendezvous.Contracts": "[1.0.0, )",
"LiteNetLib": "[2.1.4, )",
"Microsoft.AspNetCore.OpenApi": "[10.0.9, )"
}
},
"finalfactory.rendezvous.testclient": {
"type": "Project",
"dependencies": {
"FinalFactory.Rendezvous.Client": "[1.0.0, )",
"FinalFactory.Rendezvous.Contracts": "[1.0.0, )",
"LiteNetLib": "[2.1.4, )"
}
},
"LiteNetLib": {
"type": "CentralTransitive",
"requested": "[2.1.4, )",
"resolved": "2.1.4",
"contentHash": "KWlxvMw3Urpqj9joD96LRiK+LC62pQNs/zkXRJc+rHnxgkGp+vV703xzDrxRmv+V1YhCFfIGzs5nrVWtREIlyA=="
},
"Microsoft.AspNetCore.OpenApi": {
"type": "CentralTransitive",
"requested": "[10.0.9, )",
"resolved": "10.0.9",
"contentHash": "1ihb8FO9cGgEK1/m3CTtT/SfnynwmiZib0W2pcDVj3KSWk/Sca4VOXEtaptKQc582zpFrzTFiwkGRCglt6H+WQ==",
"dependencies": {
"Microsoft.OpenApi": "2.0.0"
}
},
"Microsoft.OpenApi": {
"type": "CentralTransitive",
"requested": "[2.7.5, )",
"resolved": "2.7.5",
"contentHash": "0FA67RSnRM4tcBKqiqVu/HPdZ9+QOKbmeRjxRUGTCjPU4C0bmUhd97Dso7Yild5P7nOV6GxJ2xrK0Kv/O9xp0w=="
}
}
}
}