Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 69c8b2d2bc | |||
| e626b89909 | |||
| 286fbfeb36 |
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,8 @@
|
||||
**/bin/
|
||||
**/obj/
|
||||
TestResults/
|
||||
.idea/
|
||||
.vs/
|
||||
*.suo
|
||||
*.user
|
||||
*.userosscache
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
@@ -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 | 1–10,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.
|
||||
@@ -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, 1–65535. |
|
||||
| next | 1 | Capability length, 1–192. |
|
||||
| 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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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=="
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user