diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..c684b82
--- /dev/null
+++ b/.editorconfig
@@ -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
diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml
new file mode 100644
index 0000000..f47098f
--- /dev/null
+++ b/.gitea/workflows/ci.yml
@@ -0,0 +1,34 @@
+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: Test
+ run: dotnet test Rendezvous.slnx --configuration Release --no-build
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..6c8fa88
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,8 @@
+**/bin/
+**/obj/
+TestResults/
+.idea/
+.vs/
+*.suo
+*.user
+*.userosscache
diff --git a/Directory.Build.props b/Directory.Build.props
new file mode 100644
index 0000000..e1a705e
--- /dev/null
+++ b/Directory.Build.props
@@ -0,0 +1,15 @@
+
+
+ latest-recommended
+ true
+ true
+ true
+ true
+ enable
+ latest
+ enable
+ true
+ true
+ true
+
+
diff --git a/Directory.Packages.props b/Directory.Packages.props
new file mode 100644
index 0000000..6885e54
--- /dev/null
+++ b/Directory.Packages.props
@@ -0,0 +1,12 @@
+
+
+ true
+ true
+
+
+
+
+
+
+
+
diff --git a/NuGet.config b/NuGet.config
new file mode 100644
index 0000000..4e800bf
--- /dev/null
+++ b/NuGet.config
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/README.md b/README.md
index b83b61f..07a013d 100644
--- a/README.md
+++ b/README.md
@@ -79,3 +79,21 @@ Rendezvous is currently in its initial design and bootstrap stage. The first imp
The ratified v1 boundaries, trust decisions, privacy rules, safety budgets, and
threat model are indexed in [the architecture documentation](docs/architecture/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).
diff --git a/Rendezvous.slnx b/Rendezvous.slnx
new file mode 100644
index 0000000..b375c75
--- /dev/null
+++ b/Rendezvous.slnx
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/architecture/project-boundaries.md b/docs/architecture/project-boundaries.md
new file mode 100644
index 0000000..a518eef
--- /dev/null
+++ b/docs/architecture/project-boundaries.md
@@ -0,0 +1,45 @@
+# 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.
+- `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 and LiteNetLib, 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.
+
+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)
diff --git a/global.json b/global.json
new file mode 100644
index 0000000..a5f940e
--- /dev/null
+++ b/global.json
@@ -0,0 +1,7 @@
+{
+ "sdk": {
+ "version": "10.0.301",
+ "rollForward": "disable",
+ "allowPrerelease": false
+ }
+}
diff --git a/src/FinalFactory.Rendezvous.Client/FinalFactory.Rendezvous.Client.csproj b/src/FinalFactory.Rendezvous.Client/FinalFactory.Rendezvous.Client.csproj
new file mode 100644
index 0000000..b1d411c
--- /dev/null
+++ b/src/FinalFactory.Rendezvous.Client/FinalFactory.Rendezvous.Client.csproj
@@ -0,0 +1,14 @@
+
+
+ netstandard2.1
+ FinalFactory.Rendezvous.Client
+ FinalFactory.Rendezvous.Client
+ true
+ FinalFactory.Rendezvous.Client
+ Godot-independent client SDK for Final Factory Rendezvous.
+
+
+
+
+
+
diff --git a/src/FinalFactory.Rendezvous.Client/packages.lock.json b/src/FinalFactory.Rendezvous.Client/packages.lock.json
new file mode 100644
index 0000000..fc4b597
--- /dev/null
+++ b/src/FinalFactory.Rendezvous.Client/packages.lock.json
@@ -0,0 +1,16 @@
+{
+ "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=="
+ },
+ "finalfactory.rendezvous.contracts": {
+ "type": "Project"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/FinalFactory.Rendezvous.Contracts/FinalFactory.Rendezvous.Contracts.csproj b/src/FinalFactory.Rendezvous.Contracts/FinalFactory.Rendezvous.Contracts.csproj
new file mode 100644
index 0000000..1384e57
--- /dev/null
+++ b/src/FinalFactory.Rendezvous.Contracts/FinalFactory.Rendezvous.Contracts.csproj
@@ -0,0 +1,10 @@
+
+
+ netstandard2.1
+ FinalFactory.Rendezvous.Contracts
+ FinalFactory.Rendezvous.Contracts
+ true
+ FinalFactory.Rendezvous.Contracts
+ Versioned transport-neutral contracts for Final Factory Rendezvous.
+
+
diff --git a/src/FinalFactory.Rendezvous.Contracts/packages.lock.json b/src/FinalFactory.Rendezvous.Contracts/packages.lock.json
new file mode 100644
index 0000000..a2617b3
--- /dev/null
+++ b/src/FinalFactory.Rendezvous.Contracts/packages.lock.json
@@ -0,0 +1,6 @@
+{
+ "version": 2,
+ "dependencies": {
+ ".NETStandard,Version=v2.1": {}
+ }
+}
\ No newline at end of file
diff --git a/src/FinalFactory.Rendezvous.Server/FinalFactory.Rendezvous.Server.csproj b/src/FinalFactory.Rendezvous.Server/FinalFactory.Rendezvous.Server.csproj
new file mode 100644
index 0000000..eff1ba5
--- /dev/null
+++ b/src/FinalFactory.Rendezvous.Server/FinalFactory.Rendezvous.Server.csproj
@@ -0,0 +1,12 @@
+
+
+ net10.0
+ FinalFactory.Rendezvous.Server
+ FinalFactory.Rendezvous.Server
+ false
+
+
+
+
+
+
diff --git a/src/FinalFactory.Rendezvous.Server/Program.cs b/src/FinalFactory.Rendezvous.Server/Program.cs
new file mode 100644
index 0000000..e91eec1
--- /dev/null
+++ b/src/FinalFactory.Rendezvous.Server/Program.cs
@@ -0,0 +1,31 @@
+using System.Net;
+using FinalFactory.Rendezvous.Server.Transport;
+
+WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
+
+builder.Services
+ .AddOptions()
+ .BindConfiguration(UdpMediatorOptions.SectionName)
+ .ValidateDataAnnotations()
+ .Validate(
+ options => IPAddress.TryParse(options.ListenAddress, out _),
+ $"{UdpMediatorOptions.SectionName}:ListenAddress must be an IP address.")
+ .ValidateOnStart();
+builder.Services.AddSingleton();
+builder.Services.AddHostedService(static services => services.GetRequiredService());
+
+WebApplication app = builder.Build();
+
+app.MapGet("/health/live", static () => Results.Ok(new { status = "live" }));
+app.MapGet(
+ "/health/ready",
+ static (UdpMediatorService mediator) => mediator.LocalEndpoint is null
+ ? Results.StatusCode(StatusCodes.Status503ServiceUnavailable)
+ : Results.Ok(new { status = "ready" }));
+
+await app.RunAsync();
+
+///
+/// Entry point marker used by integration-test hosts.
+///
+public partial class Program;
diff --git a/src/FinalFactory.Rendezvous.Server/Transport/UdpMediatorOptions.cs b/src/FinalFactory.Rendezvous.Server/Transport/UdpMediatorOptions.cs
new file mode 100644
index 0000000..2e8b389
--- /dev/null
+++ b/src/FinalFactory.Rendezvous.Server/Transport/UdpMediatorOptions.cs
@@ -0,0 +1,26 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace FinalFactory.Rendezvous.Server.Transport;
+
+///
+/// Configures the UDP endpoint reserved for the Rendezvous mediator.
+///
+public sealed class UdpMediatorOptions
+{
+ ///
+ /// Configuration section containing UDP mediator settings.
+ ///
+ public const string SectionName = "Rendezvous:Udp";
+
+ ///
+ /// Gets or sets the numeric IP address to bind.
+ ///
+ [Required]
+ public string ListenAddress { get; set; } = "0.0.0.0";
+
+ ///
+ /// Gets or sets the UDP port. Zero requests an ephemeral port for tests.
+ ///
+ [Range(0, 65_535)]
+ public int Port { get; set; } = 9050;
+}
diff --git a/src/FinalFactory.Rendezvous.Server/Transport/UdpMediatorService.cs b/src/FinalFactory.Rendezvous.Server/Transport/UdpMediatorService.cs
new file mode 100644
index 0000000..11232df
--- /dev/null
+++ b/src/FinalFactory.Rendezvous.Server/Transport/UdpMediatorService.cs
@@ -0,0 +1,116 @@
+using System.Net;
+using System.Net.Sockets;
+using Microsoft.Extensions.Options;
+
+namespace FinalFactory.Rendezvous.Server.Transport;
+
+///
+/// Owns the cancellable UDP socket used by the future NAT mediator.
+///
+public sealed partial class UdpMediatorService : BackgroundService
+{
+ private readonly ILogger _logger;
+ private readonly UdpMediatorOptions _options;
+ private UdpClient? _udpClient;
+
+ ///
+ /// Initializes a new UDP mediator service.
+ ///
+ public UdpMediatorService(
+ IOptions options,
+ ILogger logger)
+ {
+ _options = options.Value;
+ _logger = logger;
+ }
+
+ ///
+ /// Gets the bound endpoint after startup completes.
+ ///
+ public IPEndPoint? LocalEndpoint { get; private set; }
+
+ ///
+ 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);
+ }
+
+ ///
+ public override async Task StopAsync(CancellationToken cancellationToken)
+ {
+ await base.StopAsync(cancellationToken).ConfigureAwait(false);
+ _udpClient?.Dispose();
+ _udpClient = null;
+ LocalEndpoint = null;
+ LogMediatorStopped(_logger);
+ }
+
+ ///
+ public override void Dispose()
+ {
+ _udpClient?.Dispose();
+ _udpClient = null;
+ LocalEndpoint = null;
+ base.Dispose();
+ }
+
+ ///
+ 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);
+}
diff --git a/src/FinalFactory.Rendezvous.Server/appsettings.json b/src/FinalFactory.Rendezvous.Server/appsettings.json
new file mode 100644
index 0000000..966bfca
--- /dev/null
+++ b/src/FinalFactory.Rendezvous.Server/appsettings.json
@@ -0,0 +1,15 @@
+{
+ "Rendezvous": {
+ "Udp": {
+ "ListenAddress": "0.0.0.0",
+ "Port": 9050
+ }
+ },
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "AllowedHosts": "*"
+}
diff --git a/src/FinalFactory.Rendezvous.Server/packages.lock.json b/src/FinalFactory.Rendezvous.Server/packages.lock.json
new file mode 100644
index 0000000..8ca1330
--- /dev/null
+++ b/src/FinalFactory.Rendezvous.Server/packages.lock.json
@@ -0,0 +1,16 @@
+{
+ "version": 2,
+ "dependencies": {
+ "net10.0": {
+ "LiteNetLib": {
+ "type": "Direct",
+ "requested": "[2.1.4, )",
+ "resolved": "2.1.4",
+ "contentHash": "KWlxvMw3Urpqj9joD96LRiK+LC62pQNs/zkXRJc+rHnxgkGp+vV703xzDrxRmv+V1YhCFfIGzs5nrVWtREIlyA=="
+ },
+ "finalfactory.rendezvous.contracts": {
+ "type": "Project"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/FinalFactory.Rendezvous.TestClient/FinalFactory.Rendezvous.TestClient.csproj b/src/FinalFactory.Rendezvous.TestClient/FinalFactory.Rendezvous.TestClient.csproj
new file mode 100644
index 0000000..804e4a3
--- /dev/null
+++ b/src/FinalFactory.Rendezvous.TestClient/FinalFactory.Rendezvous.TestClient.csproj
@@ -0,0 +1,14 @@
+
+
+ Exe
+ net8.0
+ FinalFactory.Rendezvous.TestClient
+ FinalFactory.Rendezvous.TestClient
+ false
+
+
+
+
+
+
+
diff --git a/src/FinalFactory.Rendezvous.TestClient/Program.cs b/src/FinalFactory.Rendezvous.TestClient/Program.cs
new file mode 100644
index 0000000..a0a5ba0
--- /dev/null
+++ b/src/FinalFactory.Rendezvous.TestClient/Program.cs
@@ -0,0 +1,16 @@
+namespace FinalFactory.Rendezvous.TestClient;
+
+///
+/// Bootstrap entry point for the public-SDK-only diagnostic client.
+///
+public static class Program
+{
+ ///
+ /// Runs the bootstrap diagnostic.
+ ///
+ public static int Main()
+ {
+ Console.WriteLine("Rendezvous TestClient bootstrap is ready.");
+ return 0;
+ }
+}
diff --git a/src/FinalFactory.Rendezvous.TestClient/packages.lock.json b/src/FinalFactory.Rendezvous.TestClient/packages.lock.json
new file mode 100644
index 0000000..fda156e
--- /dev/null
+++ b/src/FinalFactory.Rendezvous.TestClient/packages.lock.json
@@ -0,0 +1,23 @@
+{
+ "version": 2,
+ "dependencies": {
+ "net8.0": {
+ "LiteNetLib": {
+ "type": "Direct",
+ "requested": "[2.1.4, )",
+ "resolved": "2.1.4",
+ "contentHash": "KWlxvMw3Urpqj9joD96LRiK+LC62pQNs/zkXRJc+rHnxgkGp+vV703xzDrxRmv+V1YhCFfIGzs5nrVWtREIlyA=="
+ },
+ "finalfactory.rendezvous.client": {
+ "type": "Project",
+ "dependencies": {
+ "FinalFactory.Rendezvous.Contracts": "[1.0.0, )",
+ "LiteNetLib": "[2.1.4, )"
+ }
+ },
+ "finalfactory.rendezvous.contracts": {
+ "type": "Project"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/tests/FinalFactory.Rendezvous.Tests/Architecture/DependencyBoundaryTests.cs b/tests/FinalFactory.Rendezvous.Tests/Architecture/DependencyBoundaryTests.cs
new file mode 100644
index 0000000..d9b627f
--- /dev/null
+++ b/tests/FinalFactory.Rendezvous.Tests/Architecture/DependencyBoundaryTests.cs
@@ -0,0 +1,113 @@
+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",
+ [],
+ []);
+ 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"]);
+ 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);
+}
diff --git a/tests/FinalFactory.Rendezvous.Tests/FinalFactory.Rendezvous.Tests.csproj b/tests/FinalFactory.Rendezvous.Tests/FinalFactory.Rendezvous.Tests.csproj
new file mode 100644
index 0000000..a6e6d97
--- /dev/null
+++ b/tests/FinalFactory.Rendezvous.Tests/FinalFactory.Rendezvous.Tests.csproj
@@ -0,0 +1,23 @@
+
+
+ net10.0
+ FinalFactory.Rendezvous.Tests
+ FinalFactory.Rendezvous.Tests
+ false
+ true
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
+
diff --git a/tests/FinalFactory.Rendezvous.Tests/GlobalUsings.cs b/tests/FinalFactory.Rendezvous.Tests/GlobalUsings.cs
new file mode 100644
index 0000000..c802f44
--- /dev/null
+++ b/tests/FinalFactory.Rendezvous.Tests/GlobalUsings.cs
@@ -0,0 +1 @@
+global using Xunit;
diff --git a/tests/FinalFactory.Rendezvous.Tests/Server/UdpMediatorServiceTests.cs b/tests/FinalFactory.Rendezvous.Tests/Server/UdpMediatorServiceTests.cs
new file mode 100644
index 0000000..87030f8
--- /dev/null
+++ b/tests/FinalFactory.Rendezvous.Tests/Server/UdpMediatorServiceTests.cs
@@ -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.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);
+ }
+}
diff --git a/tests/FinalFactory.Rendezvous.Tests/packages.lock.json b/tests/FinalFactory.Rendezvous.Tests/packages.lock.json
new file mode 100644
index 0000000..bbf07c8
--- /dev/null
+++ b/tests/FinalFactory.Rendezvous.Tests/packages.lock.json
@@ -0,0 +1,129 @@
+{
+ "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, )"
+ }
+ },
+ "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=="
+ }
+ }
+ }
+}
\ No newline at end of file