diff --git a/.gitattributes b/.gitattributes
index 464971cb..72bff4bf 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -1 +1,9 @@
-* text=auto eol=lf
+# Set default behavior to automatically normalize line endings
+* text=auto
+
+# Force CRLF line endings for C# files
+*.cs text eol=crlf
+
+# Force CRLF for other common Windows development files (optional)
+*.csproj text eol=crlf
+*.sln text eol=crlf
diff --git a/.gitignore b/.gitignore
index 87860e10..22093f15 100644
--- a/.gitignore
+++ b/.gitignore
@@ -306,3 +306,6 @@ MultiplayerAssets/ProjectSettings/*
!MultiplayerAssets/ProjectSettings/ProjectVersion.txt
# Packages
!MultiplayerAssets/Packages
+/Lobby Servers/Rust Server/target
+*.pem
+/docs
diff --git a/Convert-ToGitHubWiki.ps1 b/Convert-ToGitHubWiki.ps1
new file mode 100644
index 00000000..1dea901a
--- /dev/null
+++ b/Convert-ToGitHubWiki.ps1
@@ -0,0 +1,348 @@
+param(
+ [Parameter(Mandatory=$false)]
+ [string]$InputPath = ".",
+
+ [Parameter(Mandatory=$false)]
+ [string]$OutputPath = "./wiki-output",
+
+ [Parameter(Mandatory=$false)]
+ [switch]$Flatten,
+
+ [Parameter(Mandatory=$false)]
+ [switch]$InPlace
+)
+
+<#
+.SYNOPSIS
+ Converts XMLDoc2Markdown output to GitHub Wiki compatible format.
+
+.DESCRIPTION
+ This script processes markdown files generated by XMLDoc2Markdown and converts them
+ to be compatible with GitHub Wiki by:
+ - Removing .md extensions from links
+ - Removing ./ prefixes from links
+ - Optionally flattening all files into a single markdown file
+ - Optionally modifying files in place
+
+.NOTES
+ Created with assistance from GitHub Copilot
+
+.PARAMETER InputPath
+ The path to the directory containing the markdown files. Default is current directory.
+
+.PARAMETER OutputPath
+ The path where converted files will be saved. Default is ./wiki-output.
+ Ignored if -InPlace is used.
+
+.PARAMETER Flatten
+ If specified, all markdown files will be combined into a single file.
+
+.PARAMETER InPlace
+ If specified, modifies files in place instead of creating copies.
+
+.EXAMPLE
+ .\Convert-ToGitHubWiki.ps1
+ Converts all markdown files in current directory to wiki-output folder.
+
+.EXAMPLE
+ .\Convert-ToGitHubWiki.ps1 -InPlace
+ Modifies all markdown files in place.
+
+.EXAMPLE
+ .\Convert-ToGitHubWiki.ps1 -Flatten -OutputPath "./wiki.md"
+ Combines all markdown files into a single wiki.md file.
+#>
+
+function Convert-MarkdownLinks {
+ param(
+ [string]$Content,
+ [bool]$UseAnchors = $false
+ )
+
+ if ($UseAnchors) {
+ # Convert relative links to anchor links for flattened output
+ # Match patterns like [Text](./path/file.md) or [Text](path/file.md)
+ $Content = [regex]::Replace($Content, '\[([^\]]+)\]\(\.?/?([^)]+?)(?:\.md)?\)', {
+ param($match)
+ $linkText = $match.Groups[1].Value
+ $linkPath = $match.Groups[2].Value
+
+ # Skip external links (http/https)
+ if ($linkPath -match '^https?://') {
+ return $match.Value
+ }
+
+ # Convert path to anchor
+ # Remove path separators and convert to lowercase
+ $anchor = $linkPath -replace '[/\\]', '-' -replace '[^a-zA-Z0-9\s-]', '' -replace '\s+', '-' -replace '-+', '-'
+ $anchor = $anchor.ToLower().Trim('-')
+
+ return "[$linkText](#$anchor)"
+ })
+ } else {
+ # For GitHub Wiki: Convert links to use only filename (no directory path)
+ # Match patterns like [Text](./path/to/file.md) or [Text](path/to/file.md#anchor)
+ $Content = [regex]::Replace($Content, '\[([^\]]+)\]\(\.?/?([^)#]+?)(?:\.md)?(#[^)]+)?\)', {
+ param($match)
+ $linkText = $match.Groups[1].Value
+ $linkPath = $match.Groups[2].Value
+ $anchor = $match.Groups[3].Value
+
+ # Skip external links (http/https)
+ if ($linkPath -match '^https?://') {
+ return $match.Value
+ }
+
+ # Extract just the filename from the path (remove directory)
+ $filename = Split-Path -Path $linkPath -Leaf
+
+ # Return with just filename and optional anchor
+ return "[$linkText]($filename$anchor)"
+ })
+ }
+
+ return $Content
+}
+
+function Get-MarkdownFiles {
+ param(
+ [string]$Path,
+ [string]$ExcludePath = $null,
+ [switch]$ExcludeIndex
+ )
+
+ $files = Get-ChildItem -Path $Path -Filter "*.md" -Recurse | Where-Object { $_.Name -ne "README.md" }
+
+ # Exclude index.md if requested (for flattened output which generates its own TOC)
+ if ($ExcludeIndex) {
+ $files = $files | Where-Object { $_.Name -ne "index.md" }
+ }
+
+ # Exclude files in the output directory
+ if ($ExcludePath) {
+ $excludeFullPath = (Resolve-Path -Path $ExcludePath -ErrorAction SilentlyContinue)
+ if ($excludeFullPath) {
+ $files = $files | Where-Object { !$_.FullName.StartsWith($excludeFullPath) }
+ }
+ }
+
+ return $files
+}
+
+function Flatten-ToSingleFile {
+ param(
+ [string]$InputPath,
+ [string]$OutputFile
+ )
+
+ Write-Host "Flattening markdown files into single document..." -ForegroundColor Cyan
+
+ # Get output directory to exclude it from processing
+ $outputDir = Split-Path -Path $OutputFile -Parent
+ if ($outputDir) {
+ $outputDir = Join-Path -Path $InputPath -ChildPath $outputDir
+ }
+
+ $files = Get-MarkdownFiles -Path $InputPath -ExcludePath $outputDir -ExcludeIndex | Sort-Object FullName
+
+ $combinedContent = @()
+
+ # Build mapping of file paths to their heading anchors (filename -> heading anchor)
+ $fileToAnchor = @{}
+
+ foreach ($file in $files) {
+ $relativePath = $file.FullName.Substring($InputPath.Length).TrimStart('\', '/')
+ $content = Get-Content -Path $file.FullName -Raw -Encoding UTF8
+
+ # Extract first heading
+ if ($content -match '^#\s+(.+)') {
+ $heading = $matches[1].Trim()
+ $anchor = $heading -replace '[^a-zA-Z0-9\s-]', '' -replace '\s+', '-' -replace '-+', '-'
+ $anchor = $anchor.ToLower().Trim('-')
+
+ # Store mapping: filename (without extension) -> anchor
+ $filename = [System.IO.Path]::GetFileNameWithoutExtension($file.Name)
+ $fileToAnchor[$filename.ToLower()] = $anchor
+
+ # Also store full relative path mapping (with forward slashes, no extension)
+ $pathWithoutExt = $relativePath -replace '\.md$', '' -replace '\\', '/'
+ $fileToAnchor[$pathWithoutExt.ToLower()] = $anchor
+ }
+ }
+
+ # Add table of contents header
+ $combinedContent += "## Table of Contents"
+ $combinedContent += ""
+
+ # Build hierarchical TOC based on namespace from files
+ $tocStructure = @{}
+
+ foreach ($file in $files) {
+ $relativePath = $file.FullName.Substring($InputPath.Length).TrimStart('\', '/')
+ $content = Get-Content -Path $file.FullName -Raw -Encoding UTF8
+
+ # Extract first heading and namespace
+ $heading = $null
+ $namespace = $null
+
+ if ($content -match '^#\s+(.+)') {
+ $heading = $matches[1].Trim()
+ }
+
+ if ($content -match 'Namespace:\s*([^\r\n]+)') {
+ $namespace = $matches[1].Trim()
+ }
+
+ if ($heading) {
+ $anchor = $heading -replace '[^a-zA-Z0-9\s-]', '' -replace '\s+', '-' -replace '-+', '-'
+ $anchor = $anchor.ToLower().Trim('-')
+
+ # Use namespace if available, otherwise use root
+ $category = if ($namespace) { $namespace } else { '_root' }
+
+ if (!$tocStructure.ContainsKey($category)) {
+ $tocStructure[$category] = @()
+ }
+ $tocStructure[$category] += @{ Heading = $heading; Anchor = $anchor }
+ }
+ }
+
+ # Output TOC with sections
+ $orderedCategories = $tocStructure.Keys | Sort-Object | Where-Object { $_ -ne '_root' }
+
+ # Root items first if any
+ if ($tocStructure.ContainsKey('_root')) {
+ foreach ($item in $tocStructure['_root']) {
+ $combinedContent += "- [$($item.Heading)](#$($item.Anchor))"
+ }
+ $combinedContent += ""
+ }
+
+ # Then categorized items
+ foreach ($category in $orderedCategories) {
+ $combinedContent += "### $category"
+ $combinedContent += ""
+ foreach ($item in $tocStructure[$category]) {
+ $combinedContent += "- [$($item.Heading)](#$($item.Anchor))"
+ }
+ $combinedContent += ""
+ }
+
+ $combinedContent += "---"
+ $combinedContent += "
"
+ $combinedContent += ""
+
+ # Add content from each file
+ foreach ($file in $files) {
+ $relativePath = $file.FullName.Substring($InputPath.Length).TrimStart('\', '/')
+ Write-Host " Processing: $relativePath"
+
+ $content = Get-Content -Path $file.FullName -Raw -Encoding UTF8
+
+ # Convert links by replacing each known path with its anchor
+ foreach ($path in $fileToAnchor.Keys) {
+ $anchor = $fileToAnchor[$path]
+ $escapedPath = [regex]::Escape($path)
+ # Replace links like [Text](path) with [Text](#anchor)
+ $content = $content -replace "\]\($escapedPath\)", "](#$anchor)"
+ # Also handle links with existing fragment anchors - preserve the fragment
+ # but this is tricky, so for now just do the simple case
+ }
+
+ # Add source file comment
+ $combinedContent += ""
+ $combinedContent += ""
+
+ # Add content
+ $combinedContent += $content.TrimEnd()
+ $combinedContent += ""
+ $combinedContent += "---"
+ $combinedContent += "
"
+ $combinedContent += ""
+ }
+
+ # Ensure output directory exists
+ if ($outputDir -and !(Test-Path -Path $outputDir)) {
+ New-Item -Path $outputDir -ItemType Directory -Force | Out-Null
+ }
+
+ # Write combined file
+ $combinedContent -join "`n" | Set-Content -Path $OutputFile -Encoding UTF8 -NoNewline
+
+ Write-Host "Created flattened file: $OutputFile" -ForegroundColor Green
+ Write-Host "Processed $($files.Count) files" -ForegroundColor Green
+}
+
+function Convert-Files {
+ param(
+ [string]$InputPath,
+ [string]$OutputPath,
+ [bool]$InPlace
+ )
+
+ Write-Host "Converting markdown files for GitHub Wiki..." -ForegroundColor Cyan
+
+ $files = Get-MarkdownFiles -Path $InputPath
+ $count = 0
+
+ foreach ($file in $files) {
+ $relativePath = $file.FullName.Substring($InputPath.Length).TrimStart('\', '/')
+ Write-Host " Processing: $relativePath"
+
+ $content = Get-Content -Path $file.FullName -Raw -Encoding UTF8
+ $convertedContent = Convert-MarkdownLinks -Content $content
+
+ if ($InPlace) {
+ $outputFile = $file.FullName
+ } else {
+ $outputFile = Join-Path -Path $OutputPath -ChildPath $relativePath
+ $outputDir = Split-Path -Path $outputFile -Parent
+
+ if (!(Test-Path -Path $outputDir)) {
+ New-Item -Path $outputDir -ItemType Directory -Force | Out-Null
+ }
+ }
+
+ $convertedContent | Set-Content -Path $outputFile -Encoding UTF8 -NoNewline
+ $count++
+ }
+
+ if ($InPlace) {
+ Write-Host "Modified $count files in place" -ForegroundColor Green
+ } else {
+ Write-Host "Converted $count files to: $OutputPath" -ForegroundColor Green
+ }
+}
+
+# Main execution
+try {
+ # Resolve full path
+ $InputPath = Resolve-Path -Path $InputPath -ErrorAction Stop
+
+ if ($Flatten) {
+ # Determine output file path
+ if ($OutputPath -like "*.md") {
+ $flattenedFile = $OutputPath
+ } else {
+ $flattenedFile = Join-Path -Path $OutputPath -ChildPath "API-Documentation.md"
+ }
+
+ Flatten-ToSingleFile -InputPath $InputPath -OutputFile $flattenedFile
+ } else {
+ if ($InPlace) {
+ $confirmation = Read-Host "This will modify files in place. Continue? (y/n)"
+ if ($confirmation -ne 'y') {
+ Write-Host "Operation cancelled." -ForegroundColor Yellow
+ exit
+ }
+ }
+
+ Convert-Files -InputPath $InputPath -OutputPath $OutputPath -InPlace $InPlace
+ }
+
+ Write-Host "`nConversion complete!" -ForegroundColor Green
+
+} catch {
+ Write-Host "Error: $_" -ForegroundColor Red
+ exit 1
+}
diff --git a/Directory.Build.targets.EXAMPLE b/Directory.Build.targets.EXAMPLE
new file mode 100644
index 00000000..29314c43
--- /dev/null
+++ b/Directory.Build.targets.EXAMPLE
@@ -0,0 +1,25 @@
+
+
+
+ C:\Program Files (x86)\Steam\steamapps\common\Derail Valley
+ C:\Program Files\Unity\Hub\Editor\2019.4.40f1\Editor
+
+ $(DvInstallDir)\DerailValley_Data\Managed\;
+ $(DvInstallDir)\DerailValley_Data\Managed\UnityModManager\;
+ $(UnityInstallDir)\Data\Managed\
+
+ $(AssemblySearchPaths);$(ReferencePath);
+ C:\Program Files (x86)\Microsoft SDKs\ClickOnce\SignTool\
+ 7cf2b8a98a09ffd407ada2e94f200af24a0e68bc
+
+
\ No newline at end of file
diff --git a/Lobby Servers/PHP Server/.htaccess b/Lobby Servers/PHP Server/.htaccess
new file mode 100644
index 00000000..c8f09176
--- /dev/null
+++ b/Lobby Servers/PHP Server/.htaccess
@@ -0,0 +1,11 @@
+# Enable the RewriteEngine
+RewriteEngine On
+
+# Uncomment below to force HTTPS
+# RewriteCond %{HTTPS} off
+# RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
+
+# Redirect all non-existing paths to index.php
+RewriteCond %{REQUEST_FILENAME} !-f
+RewriteCond %{REQUEST_FILENAME} !-d
+RewriteRule ^ index.php [QSA,L]
\ No newline at end of file
diff --git a/Lobby Servers/PHP Server/DatabaseInterface.php b/Lobby Servers/PHP Server/DatabaseInterface.php
new file mode 100644
index 00000000..ae751d42
--- /dev/null
+++ b/Lobby Servers/PHP Server/DatabaseInterface.php
@@ -0,0 +1,10 @@
+
diff --git a/Lobby Servers/PHP Server/FlatfileDatabase.php b/Lobby Servers/PHP Server/FlatfileDatabase.php
new file mode 100644
index 00000000..c649d397
--- /dev/null
+++ b/Lobby Servers/PHP Server/FlatfileDatabase.php
@@ -0,0 +1,99 @@
+filePath = $dbConfig['flatfile_path'];
+ }
+
+ private function readData() {
+ if (!file_exists($this->filePath)) {
+ return [];
+ }
+ return json_decode(file_get_contents($this->filePath), true) ?? [];
+ }
+
+ private function writeData($data) {
+ file_put_contents($this->filePath, json_encode($data, JSON_PRETTY_PRINT));
+ }
+
+ public function addGameServer($data) {
+ $data['last_update'] = time(); // Set current time as last_update
+
+ $servers = $this->readData();
+ $servers[] = $data;
+ $this->writeData($servers);
+
+ return json_encode([
+ "game_server_id" => $data['game_server_id'],
+ "private_key" => $data['private_key'],
+ ]);
+ }
+
+ public function updateGameServer($data) {
+ $servers = $this->readData();
+ $updated = false;
+
+ foreach ($servers as &$server) {
+ if ($server['game_server_id'] === $data['game_server_id']) {
+ $server['current_players'] = $data['current_players'];
+ $server['time_passed'] = $data['time_passed'];
+ $server['last_update'] = time(); // Update with current time
+
+ $updated = true;
+ break;
+ }
+ }
+
+ if ($updated) {
+ $this->writeData($servers);
+ return json_encode(["message" => "Server updated"]);
+ } else {
+ return json_encode(["error" => "Failed to update server"]);
+ }
+ }
+
+ public function removeGameServer($data) {
+ $servers = $this->readData();
+ $servers = array_filter($servers, function($server) use ($data) {
+ return $server['game_server_id'] !== $data['game_server_id'];
+ });
+ $this->writeData(array_values($servers));
+ return json_encode(["message" => "Server removed"]);
+ }
+
+ public function listGameServers() {
+ $servers = $this->readData();
+ $current_time = time();
+ $active_servers = [];
+ $changed = false;
+
+ foreach ($servers as $key => $server) {
+ if ($current_time - $server['last_update'] <= TIMEOUT) {
+ $active_servers[] = $server;
+ } else {
+ $changed = true; // Indicates there's a change if any server is removed
+ }
+ }
+
+ if ($changed) {
+ $this->writeData($active_servers); // Write back only if there are changes
+ }
+
+ return json_encode($active_servers);
+ }
+
+ public function getGameServer($game_server_id) {
+ $servers = $this->readData();
+ foreach ($servers as $server) {
+ if ($server['game_server_id'] === $game_server_id) {
+ return json_encode($server);
+ }
+ }
+ return json_encode(null);
+ }
+}
+
+?>
diff --git a/Lobby Servers/PHP Server/MySQLDatabase.php b/Lobby Servers/PHP Server/MySQLDatabase.php
new file mode 100644
index 00000000..c6caf42b
--- /dev/null
+++ b/Lobby Servers/PHP Server/MySQLDatabase.php
@@ -0,0 +1,79 @@
+pdo = new PDO("mysql:host={$dbConfig['host']};dbname={$dbConfig['dbname']}", $dbConfig['username'], $dbConfig['password']);
+ $this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
+ }
+
+ public function addGameServer($data) {
+ $stmt = $this->pdo->prepare("INSERT INTO game_servers (game_server_id, private_key, ipv4, ipv6, port, server_name, password_protected, game_mode, difficulty, time_passed, current_players, max_players, required_mods, game_version, multiplayer_version, server_info, last_update)
+ VALUES (:game_server_id, :private_key, :ip, :port, :server_name, :password_protected, :game_mode, :difficulty, :time_passed, :current_players, :max_players, :required_mods, :game_version, :multiplayer_version, :server_info, :last_update)");
+ $stmt->execute([
+ ':game_server_id' => $data['game_server_id'],
+ ':private_key' => $data['private_key'],
+ ':ipv4' => isset($data['ipv4']) ? $data['ipv4'] : '',
+ ':ipv6' => isset($data['ipv6']) ? $data['ipv6'] : '',
+ ':port' => $data['port'],
+ ':server_name' => $data['server_name'],
+ ':password_protected' => $data['password_protected'],
+ ':game_mode' => $data['game_mode'],
+ ':difficulty' => $data['difficulty'],
+ ':time_passed' => $data['time_passed'],
+ ':current_players' => $data['current_players'],
+ ':max_players' => $data['max_players'],
+ ':required_mods' => $data['required_mods'],
+ ':game_version' => $data['game_version'],
+ ':multiplayer_version' => $data['multiplayer_version'],
+ ':server_info' => $data['server_info'],
+ ':last_update' => time() //use current time
+ ]);
+ return json_encode([
+ "game_server_id" => $data['game_server_id'],
+ "private_key" => $data['private_key']
+ ]);
+ }
+
+ public function updateGameServer($data) {
+ $stmt = $this->pdo->prepare("UPDATE game_servers
+ SET current_players = :current_players, time_passed = :time_passed, last_update = :last_update
+ WHERE game_server_id = :game_server_id");
+ $stmt->execute([
+ ':current_players' => $data['current_players'],
+ ':time_passed' => $data['time_passed'],
+ ':last_update' => time(), // Update with current time
+ ':game_server_id' => $data['game_server_id']
+ ]);
+
+ return $stmt->rowCount() > 0 ? json_encode(["message" => "Server updated"]) : json_encode(["error" => "Failed to update server"]);
+ }
+
+ public function removeGameServer($data) {
+ $stmt = $this->pdo->prepare("DELETE FROM game_servers WHERE game_server_id = :game_server_id");
+ $stmt->execute([':game_server_id' => $data['game_server_id']]);
+ return $stmt->rowCount() > 0 ? json_encode(["message" => "Server removed"]) : json_encode(["error" => "Failed to remove server"]);
+ }
+
+ public function listGameServers() {
+ // Remove servers that exceed TIMEOUT directly in the SQL query
+ $stmt = $this->pdo->prepare("DELETE FROM game_servers WHERE last_update < :timeout");
+ $stmt->execute([':timeout' => time() - TIMEOUT]);
+
+ // Fetch remaining servers
+ $stmt = $this->pdo->query("SELECT * FROM game_servers");
+ $servers = $stmt->fetchAll(PDO::FETCH_ASSOC);
+
+ return json_encode($servers);
+ }
+
+ public function getGameServer($game_server_id) {
+ $stmt = $this->pdo->prepare("SELECT * FROM game_servers WHERE game_server_id = :game_server_id");
+ $stmt->execute([':game_server_id' => $game_server_id]);
+ return json_encode($stmt->fetch(PDO::FETCH_ASSOC));
+ }
+}
+
+?>
diff --git a/Lobby Servers/PHP Server/Read Me.md b/Lobby Servers/PHP Server/Read Me.md
new file mode 100644
index 00000000..5bc4c506
--- /dev/null
+++ b/Lobby Servers/PHP Server/Read Me.md
@@ -0,0 +1,149 @@
+# Lobby Server - PHP
+
+This is a PHP implementation of the Derail Valley Lobby Server REST API service. It is designed to run on any standard web hosting and does not rely on long-running/persistent behaviour.
+HTTPS support depends on the configuration of the hosting environment.
+
+As this implementation is not persistent in memory, a database is used to store server information. Two options are available for the database engine - a JSON based flatfile or a MySQL database.
+
+## Installing
+
+The following instructions assume you will be using an Apache web server and may need to be modified for other configurations.
+
+1. Copy the following files to your public html folder (consult your web server/web host's documentation)
+```
+index.php
+DatabaseInterface.php
+FlatfileDatabase.php
+MySQLDatabase.php
+.htaccess
+```
+2. Copy `config.php` to a secure location outside of your public html directory
+3. Edit `index.php` and update the path to the config file on line 2:
+```php
+ 'mysql',
+ 'host' => 'localhost',
+ 'dbname' => 'dv_lobby',
+ 'username' => 'dv_lobby_server',
+ 'password' => 'n16O5+LMpeqI`{E',
+ 'flatfile_path' => '' // Path to store the flatfile database
+];
+?>
+```
+
+Example `config.php` using Flatfile:
+```php
+ 'flatfile',
+ 'host' => '',
+ 'dbname' => '',
+ 'username' => '',
+ 'password' => '',
+ 'flatfile_path' => '/dv_lobby/flatfile.db' // Path to store the flatfile database
+];
+?>
+```
+
+## Security Considerations
+This is a non-comprehensive overview of security considerations. You should always use up-to-date best practices and seek professional advice where required.
+
+### Environment variables
+Consider using environment variables to store sensitive database credentials (e.g. `dbConfig`.`host`, `dbConfig`.`dbname`, `dbConfig`.`username`, `dbConfig`.`password`) instead of hardcoding them in config.php.
+Your `config.php` can be updated to reference the environment variables.
+
+Example:
+```php
+$dbConfig = [
+ 'type' => 'mysql',
+ 'host' => getenv('DB_HOST'),
+ 'dbname' => getenv('DB_NAME'),
+ 'username' => getenv('DB_USER'),
+ 'password' => getenv('DB_PASSWORD'),
+ 'flatfile_path' => '/path/to/flatfile.db'
+];
+```
+
+
+### File Permissions
+Ensure that `config.php` and any other sensitive files outside the web root are only readable by the web server user (chmod 600).
+For directories containing flatfile databases, restrict permissions (chmod 700 or 750) to prevent unauthorised access.
+
+### HTTPS (SSL)
+Configure your server to use https. Many web hosts provide free SSL certificates via Let's Encrypt.
+Consider forcing https via server config/`.httaccess`.
+
+Example:
+```apacheconf
+RewriteEngine On
+RewriteCond %{HTTPS} off
+RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
+```
diff --git a/Lobby Servers/PHP Server/config.php b/Lobby Servers/PHP Server/config.php
new file mode 100644
index 00000000..52073ea0
--- /dev/null
+++ b/Lobby Servers/PHP Server/config.php
@@ -0,0 +1,16 @@
+ 'mysql', // Change to 'flatfile' to use flatfile database
+ 'host' => 'localhost',
+ 'dbname' => 'your_database',
+ 'username' => 'your_username',
+ 'password' => 'your_password',
+ 'flatfile_path' => '/path/to/flatfile.db' // Path to store the flatfile database
+];
+
+?>
\ No newline at end of file
diff --git a/Lobby Servers/PHP Server/index.php b/Lobby Servers/PHP Server/index.php
new file mode 100644
index 00000000..68751f16
--- /dev/null
+++ b/Lobby Servers/PHP Server/index.php
@@ -0,0 +1,158 @@
+ "Invalid server information"]);
+ }
+
+ $data['game_server_id'] = uuid_create();
+ $data['private_key'] = generate_private_key();
+
+ return $db->addGameServer($data);
+}
+
+function update_game_server($db, $data) {
+ if (!validate_server_update($db, $data)) {
+ http_response_code(500);
+ return json_encode(["error" => "Invalid game server ID or private key"]);
+ }
+
+ $data['last_update'] = time();
+ return $db->updateGameServer($data);
+}
+
+function remove_game_server($db, $data) {
+ if (!validate_server_update($db, $data)) {
+ return json_encode(["error" => "Invalid game server ID or private key"]);
+ }
+
+ return $db->removeGameServer($data);
+}
+
+function list_game_servers($db) {
+ $servers = json_decode($db->listGameServers(), true);
+
+ // Remove private keys from the servers before returning
+ // and select the correct protocol version for the requestor
+ foreach ($servers as &$server) {
+ unset($server['private_key']);
+ unset($server['last_update']);
+
+ if(!isset($server['ipv4'])){
+ $server['ipv4'] = '';
+ }
+
+ if(!isset($server['ipv6'])){
+ $server['ipv6'] = '';
+ }
+
+ if(filter_var($_SERVER['REMOTE_ADDR'], FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)){
+ //Host made a request on IPv4, remove IPv6 address as we assume they don't support it.
+ unset($server['ipv6']);
+
+ }
+ }
+ return json_encode($servers);
+}
+
+function validate_server_info($data) {
+
+ if(!isset($data['ipv4']) || !filter_var($data['ipv4'], FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)){
+ $data['ipv4'] = '';
+ }elseif(!isset($data['ipv6']) || !filter_var($data['ipv6'], FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)){
+ $data['ipv6'] = '';
+ }
+
+ if (
+ //make sure we have at least one IP
+ $data['ipv4'] == '' && $data['ipv6'] == '' ||
+
+ //Make sure we have all required fields
+ !isset($data['server_name']) ||
+ !isset($data['server_info']) ||
+ !isset($data['current_players']) ||
+ !isset($data['max_players']) ||
+
+ //Validate fields
+ strlen($data['server_name']) > 25 ||
+ strlen($data['server_info']) > 500 ||
+ $data['current_players'] > $data['max_players'] ||
+ $data['max_players'] < 1
+ ){
+
+ return false;
+ }
+
+ return true;
+}
+
+function validate_server_update($db, $data) {
+ $server = json_decode($db->getGameServer($data['game_server_id']), true);
+ return $server && $server['private_key'] === $data['private_key'];
+}
+
+function uuid_create() {
+ return sprintf('%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
+ mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff),
+ mt_rand(0, 0x0fff) | 0x4000,
+ mt_rand(0, 0x3fff) | 0x8000,
+ mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff)
+ );
+}
+
+function generate_private_key() {
+ // Generate a 128-bit (16 bytes) random binary string
+ $random_bytes = random_bytes(16);
+
+ // Convert the binary string to a hexadecimal representation
+ $private_key = bin2hex($random_bytes);
+
+ return $private_key;
+}
+
+?>
\ No newline at end of file
diff --git a/Lobby Servers/PHP Server/install.php b/Lobby Servers/PHP Server/install.php
new file mode 100644
index 00000000..f323dcbf
--- /dev/null
+++ b/Lobby Servers/PHP Server/install.php
@@ -0,0 +1,55 @@
+setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
+
+ // Create the database if it doesn't exist
+ $sql = "CREATE DATABASE IF NOT EXISTS " . $dbConfig['dbname'];
+ $pdo->exec($sql);
+ echo "Database created successfully.
";
+
+ // Connect to the newly created database
+ $dsn = 'mysql:host=' . $dbConfig['host'] . ';dbname=' . $dbConfig['dbname'];
+ $pdo = new PDO($dsn, $dbConfig['username'], $dbConfig['password']);
+ $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
+
+ // Create the game_servers table
+ $sql = "
+ CREATE TABLE IF NOT EXISTS game_servers (
+ game_server_id VARCHAR(50) PRIMARY KEY,
+ private_key VARCHAR(255) NOT NULL,
+ ipv4 VARCHAR(45) NOT NULL,
+ ipv6 VARCHAR(45) NOT NULL,
+ port INT NOT NULL,
+ server_name VARCHAR(100) NOT NULL,
+ password_protected BOOLEAN NOT NULL,
+ game_mode VARCHAR(50) NOT NULL,
+ difficulty VARCHAR(50) NOT NULL,
+ time_passed INT NOT NULL,
+ current_players INT NOT NULL,
+ max_players INT NOT NULL,
+ required_mods TEXT NOT NULL,
+ game_version VARCHAR(50) NOT NULL,
+ multiplayer_version VARCHAR(50) NOT NULL,
+ server_info TEXT NOT NULL,
+ last_update INT NOT NULL
+ );
+ ";
+
+ // Execute the SQL to create the table
+ $pdo->exec($sql);
+ echo "Table 'game_servers' created successfully.
";
+
+} catch (PDOException $e) {
+ die("DB ERROR: " . $e->getMessage());
+}
+?>
diff --git a/Lobby Servers/RestAPI.md b/Lobby Servers/RestAPI.md
new file mode 100644
index 00000000..cd9d574f
--- /dev/null
+++ b/Lobby Servers/RestAPI.md
@@ -0,0 +1,272 @@
+# Derail Valley Lobby Server REST API Documentation
+
+Revision: A
+Date: 2024-06-22
+
+## Overview
+
+This document describes the REST API endpoints for the Derail Valley Lobby Server service. The service allows game servers to register, update, and deregister themselves, and provides a list of active servers to clients.
+This spec does not provide the server address, as new servers can be created by anyone wishing to host their own lobby server.
+
+## Enums
+
+### Game Modes
+
+The game_mode field in the request body for adding a game server must be one of the following integer values, each representing a specific game mode:
+
+- 0: Career
+- 1: Sandbox
+- 2: Scenario
+
+### Difficulty Levels
+
+The difficulty field in the request body for adding a game server must be one of the following integer values, each representing a specific difficulty level:
+
+- 0: Standard
+- 1: Comfort
+- 2: Realistic
+- 3: Custom
+
+## Endpoints
+
+### Add Game Server
+
+- **URL:** `/add_game_server`
+- **Method:** `POST`
+- **Content-Type:** `application/json`
+- **Request Body:**
+ ```json
+ {
+ "ipv4": "string",
+ "ipv6": "string",
+ "port": "integer",
+ "server_name": "string",
+ "password_protected": "boolean",
+ "game_mode": "integer",
+ "difficulty": "integer",
+ "time_passed": "string",
+ "current_players": "integer",
+ "max_players": "integer",
+ "required_mods": "string",
+ "game_version": "string",
+ "multiplayer_version": "string",
+ "server_info": "string"
+ }
+ ```
+ - **Fields:**
+ - ipv4 (optional string): The publically accessible IPv4 address of the game server - if this is not supplied, then the IPv6 address must be.
+ - ipv6 (optional string): The publically accessible IPv4 address of the game server - if this is not supplied, then the IPv4 address must be..
+ - port (integer): The port number of the game server.
+ - server_name (string): The name of the game server (maximum 25 characters).
+ - password_protected (boolean): Indicates if the server is password-protected.
+ - game_mode (integer): The game mode (see [Game Modes](#game-modes)).
+ - difficulty (integer): The difficulty level (see [Difficulty Levels](#difficulty-levels)).
+ - time_passed (string): The in-game time passed since the game/session was started.
+ - current_players (integer): The current number of players on the server (0 - max_players).
+ - max_players (integer): The maximum number of players allowed on the server (>= 1).
+ - required_mods (string): The required mods for the server, supplied as a JSON string.
+ - game_version (string): The game version the server is running.
+ - multiplayer_version (string): The Multiplayer Mod version the server is running.
+ - server_info (string): Additional information about the server (maximum 500 characters).
+- **Response:**
+ - **Success:**
+ - **Code:** 200 OK
+ - **Content-Type:** `application/json`
+ - **Content:**
+ ```json
+ {
+ "game_server_id": "string",
+ "private_key": "string"
+ }
+ ```
+ - game_server_id (string): A GUID assigned to the game server. This GUID uniquely identifies the game server and is used when updating the lobby server.
+ - private_key (string): A shared secret between the lobby server and the game server. Must be supplied when updating the lobby server.
+ - **Error:**
+ - **Code:** 500 Internal Server Error
+ - **Content:** `"Failed to add server"`
+
+### Update Server
+
+- **URL:** `/update_game_server`
+- **Method:** `POST`
+- **Content-Type:** `application/json`
+- **Request Body:**
+ ```json
+ {
+ "game_server_id": "string",
+ "private_key": "string",
+ "current_players": "integer",
+ "time_passed": "string",
+ }
+ ```
+ - **Fields:**
+ - game_server_id (string): The GUID assigned to the game server (returned from `add_game_server`).
+ - private_key (string): The shared secret between the lobby server and the game server (returned from `add_game_server`).
+ - current_players (integer): The current number of players on the server (0 - max_players).
+ - time_passed (string): The in-game time passed since the game/session was started.
+- **Response:**
+ - **Success:**
+ - **Code:** 200 OK
+ - **Content:** `"Server updated"`
+ - **Error:**
+ - **Code:** 500 Internal Server Error
+ - **Content:** `"Failed to update server"`
+
+### Remove Server
+
+- **URL:** `/remove_game_server`
+- **Method:** `POST`
+- **Content-Type:** `application/json`
+- **Request Body:**
+ ```json
+ {
+ "game_server_id": "string",
+ "private_key": "string"
+ }
+ ```
+ - **Fields:**
+ - game_server_id (string): The GUID assigned to the game server (returned from `add_game_server`).
+ - private_key (string): The shared secret between the lobby server and the game server (returned from `add_game_server`).
+- **Response:**
+ - **Success:**
+ - **Code:** 200 OK
+ - **Content:** `"Server removed"`
+ - **Error:**
+ - **Code:** 500 Internal Server Error
+ - **Content:** `"Failed to remove server"`
+
+### List Game Servers
+
+- **URL:** `/list_game_servers`
+- **Method:** `GET`
+- **Response:**
+ - **Success:**
+ - **Code:** 200 OK
+ - **Content-Type:** `application/json`
+ - **Content:**
+ ```json
+ [
+ {
+ "ipv4": "string",
+ "ipv6": "string",
+ "port": "integer",
+ "server_name": "string",
+ "password_protected": "boolean",
+ "game_mode": "integer",
+ "difficulty": "integer",
+ "time_passed": "string",
+ "current_players": "integer",
+ "max_players": "integer",
+ "required_mods": "string",
+ "game_version": "string",
+ "multiplayer_version": "string",
+ "server_info": "string",
+ "game_server_id": "string"
+ },
+ ...
+ ]
+ ```
+ - **Fields:**
+ - ipv4 (optional string): The IPv4 address of the game server, if known.
+ - ipv6 (optional string): The IPv6 address of the game server, if known and if the end point request is made using IPv6, i.e. IPv4 clients will not be provided with the ``ipv6`` field.
+ - port (integer): The port number of the game server.
+ - server_name (string): The name of the game server (maximum 25 characters).
+ - password_protected (boolean): Indicates if the server is password-protected.
+ - game_mode (integer): The game mode (see [Game Modes](#game-modes)).
+ - difficulty (integer): The difficulty level (see [Difficulty Levels](#difficulty-levels)).
+ - time_passed (string): The in-game time passed since the game/session was started.
+ - current_players (integer): The current number of players on the server (0 - max_players).
+ - max_players (integer): The maximum number of players allowed on the server (>= 1).
+ - required_mods (string): The required mods for the server, supplied as a JSON string.
+ - game_version (string): The game version the server is running.
+ - multiplayer_version (string): The Multiplayer Mod version the server is running.
+ - server_info (string): Additional information about the server (maximum 500 characters).
+ - game_server_id (string): The GUID assigned to the game server.
+ - **Error:**
+ - **Code:** 500 Internal Server Error
+ - **Content:** `"Failed to retrieve servers"`
+
+## Example Requests
+
+### Add Game Server
+Example request:
+```bash
+curl -X POST -H "Content-Type: application/json" -d '{
+ "ipv4": "127.0.0.1",
+ "ipv6": "::1",
+ "port": 7777,
+ "server_name": "My Derail Valley Server",
+ "password_protected": false,
+ "current_players": 1,
+ "max_players": 10,
+ "game_mode": 0,
+ "difficulty": 0,
+ "time_passed": "0d 10h 45m 12s",
+ "required_mods": "",
+ "game_version": "98",
+ "multiplayer_version": "0.1.0",
+ "server_info": "License unlocked server
Join our discord and have fun!"
+}' http:///add_game_server
+```
+Example response:
+```json
+{
+ "game_server_id": "0e1759fd-ba6e-4476-ace2-f173af9db342",
+ "private_key": "6fca6e1499dab0358f79dc0b251b4e23"
+}
+```
+
+### Update Game Server
+Example request:
+```bash
+curl -X POST -H "Content-Type: application/json" -d '{
+ "game_server_id": "0e1759fd-ba6e-4476-ace2-f173af9db342",
+ "private_key": "6fca6e1499dab0358f79dc0b251b4e23",
+ "current_players": 2,
+ "time_passed": "0d 10h 47m 12s"
+}' http:///update_game_server
+```
+Example response:
+```json
+{
+ "message": "Server updated"
+}
+```
+### Remove Game Server
+Example request:
+```bash
+curl -X POST -H "Content-Type: application/json" -d '{
+ "game_server_id": "0e1759fd-ba6e-4476-ace2-f173af9db342",
+ "private_key": "6fca6e1499dab0358f79dc0b251b4e23"
+}' http:///remove_game_server
+```
+Example response:
+```json
+{
+ "message": "Server removed"
+}
+```
+
+### List Game Servers
+
+```bash
+curl http:///list_game_servers
+```
+
+## Error Handling
+
+In case of an error, the API will return a JSON response with a message indicating the failure.
+
+```json
+{
+ "error": "string"
+}
+```
+
+### Common Error Responses
+
+- **500 Internal Server Error**
+ - **Content:** `"Failed to add server"`
+ - **Content:** `"Failed to update server"`
+ - **Content:** `"Failed to remove server"`
+ - **Content:** `"Failed to retrieve servers"`
diff --git a/Lobby Servers/Rust Server/Cargo.lock b/Lobby Servers/Rust Server/Cargo.lock
new file mode 100644
index 00000000..f80e1d82
--- /dev/null
+++ b/Lobby Servers/Rust Server/Cargo.lock
@@ -0,0 +1,1540 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 3
+
+[[package]]
+name = "actix-codec"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5f7b0a21988c1bf877cf4759ef5ddaac04c1c9fe808c9142ecb78ba97d97a28a"
+dependencies = [
+ "bitflags",
+ "bytes",
+ "futures-core",
+ "futures-sink",
+ "memchr",
+ "pin-project-lite",
+ "tokio",
+ "tokio-util",
+ "tracing",
+]
+
+[[package]]
+name = "actix-http"
+version = "3.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3ae682f693a9cd7b058f2b0b5d9a6d7728a8555779bedbbc35dd88528611d020"
+dependencies = [
+ "actix-codec",
+ "actix-rt",
+ "actix-service",
+ "actix-tls",
+ "actix-utils",
+ "ahash",
+ "base64",
+ "bitflags",
+ "brotli",
+ "bytes",
+ "bytestring",
+ "derive_more",
+ "encoding_rs",
+ "flate2",
+ "futures-core",
+ "h2",
+ "http",
+ "httparse",
+ "httpdate",
+ "itoa",
+ "language-tags",
+ "local-channel",
+ "mime",
+ "percent-encoding",
+ "pin-project-lite",
+ "rand",
+ "sha1",
+ "smallvec",
+ "tokio",
+ "tokio-util",
+ "tracing",
+ "zstd",
+]
+
+[[package]]
+name = "actix-macros"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e01ed3140b2f8d422c68afa1ed2e85d996ea619c988ac834d255db32138655cb"
+dependencies = [
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "actix-router"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13d324164c51f63867b57e73ba5936ea151b8a41a1d23d1031eeb9f70d0236f8"
+dependencies = [
+ "bytestring",
+ "cfg-if",
+ "http",
+ "regex",
+ "regex-lite",
+ "serde",
+ "tracing",
+]
+
+[[package]]
+name = "actix-rt"
+version = "2.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "24eda4e2a6e042aa4e55ac438a2ae052d3b5da0ecf83d7411e1a368946925208"
+dependencies = [
+ "futures-core",
+ "tokio",
+]
+
+[[package]]
+name = "actix-server"
+version = "2.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b02303ce8d4e8be5b855af6cf3c3a08f3eff26880faad82bab679c22d3650cb5"
+dependencies = [
+ "actix-rt",
+ "actix-service",
+ "actix-utils",
+ "futures-core",
+ "futures-util",
+ "mio",
+ "socket2",
+ "tokio",
+ "tracing",
+]
+
+[[package]]
+name = "actix-service"
+version = "2.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b894941f818cfdc7ccc4b9e60fa7e53b5042a2e8567270f9147d5591893373a"
+dependencies = [
+ "futures-core",
+ "paste",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "actix-tls"
+version = "3.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac453898d866cdbecdbc2334fe1738c747b4eba14a677261f2b768ba05329389"
+dependencies = [
+ "actix-rt",
+ "actix-service",
+ "actix-utils",
+ "futures-core",
+ "impl-more",
+ "openssl",
+ "pin-project-lite",
+ "tokio",
+ "tokio-openssl",
+ "tokio-util",
+ "tracing",
+]
+
+[[package]]
+name = "actix-utils"
+version = "3.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "88a1dcdff1466e3c2488e1cb5c36a71822750ad43839937f85d2f4d9f8b705d8"
+dependencies = [
+ "local-waker",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "actix-web"
+version = "4.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1988c02af8d2b718c05bc4aeb6a66395b7cdf32858c2c71131e5637a8c05a9ff"
+dependencies = [
+ "actix-codec",
+ "actix-http",
+ "actix-macros",
+ "actix-router",
+ "actix-rt",
+ "actix-server",
+ "actix-service",
+ "actix-tls",
+ "actix-utils",
+ "actix-web-codegen",
+ "ahash",
+ "bytes",
+ "bytestring",
+ "cfg-if",
+ "cookie",
+ "derive_more",
+ "encoding_rs",
+ "futures-core",
+ "futures-util",
+ "itoa",
+ "language-tags",
+ "log",
+ "mime",
+ "once_cell",
+ "pin-project-lite",
+ "regex",
+ "regex-lite",
+ "serde",
+ "serde_json",
+ "serde_urlencoded",
+ "smallvec",
+ "socket2",
+ "time",
+ "url",
+]
+
+[[package]]
+name = "actix-web-codegen"
+version = "4.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f591380e2e68490b5dfaf1dd1aa0ebe78d84ba7067078512b4ea6e4492d622b8"
+dependencies = [
+ "actix-router",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "addr2line"
+version = "0.22.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e4503c46a5c0c7844e948c9a4d6acd9f50cccb4de1c48eb9e291ea17470c678"
+dependencies = [
+ "gimli",
+]
+
+[[package]]
+name = "adler"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe"
+
+[[package]]
+name = "ahash"
+version = "0.8.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011"
+dependencies = [
+ "cfg-if",
+ "getrandom",
+ "once_cell",
+ "version_check",
+ "zerocopy",
+]
+
+[[package]]
+name = "aho-corasick"
+version = "1.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "alloc-no-stdlib"
+version = "2.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3"
+
+[[package]]
+name = "alloc-stdlib"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece"
+dependencies = [
+ "alloc-no-stdlib",
+]
+
+[[package]]
+name = "atty"
+version = "0.2.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8"
+dependencies = [
+ "hermit-abi 0.1.19",
+ "libc",
+ "winapi",
+]
+
+[[package]]
+name = "autocfg"
+version = "1.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0"
+
+[[package]]
+name = "backtrace"
+version = "0.3.73"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5cc23269a4f8976d0a4d2e7109211a419fe30e8d88d677cd60b6bc79c5732e0a"
+dependencies = [
+ "addr2line",
+ "cc",
+ "cfg-if",
+ "libc",
+ "miniz_oxide",
+ "object",
+ "rustc-demangle",
+]
+
+[[package]]
+name = "base64"
+version = "0.22.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
+
+[[package]]
+name = "bitflags"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf4b9d6a944f767f8e5e0db018570623c85f3d925ac718db4e06d0187adb21c1"
+
+[[package]]
+name = "block-buffer"
+version = "0.10.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
+dependencies = [
+ "generic-array",
+]
+
+[[package]]
+name = "brotli"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "74f7971dbd9326d58187408ab83117d8ac1bb9c17b085fdacd1cf2f598719b6b"
+dependencies = [
+ "alloc-no-stdlib",
+ "alloc-stdlib",
+ "brotli-decompressor",
+]
+
+[[package]]
+name = "brotli-decompressor"
+version = "4.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a45bd2e4095a8b518033b128020dd4a55aab1c0a381ba4404a472630f4bc362"
+dependencies = [
+ "alloc-no-stdlib",
+ "alloc-stdlib",
+]
+
+[[package]]
+name = "bytes"
+version = "1.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "514de17de45fdb8dc022b1a7975556c53c86f9f0aa5f534b98977b171857c2c9"
+
+[[package]]
+name = "bytestring"
+version = "1.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "74d80203ea6b29df88012294f62733de21cfeab47f17b41af3a38bc30a03ee72"
+dependencies = [
+ "bytes",
+]
+
+[[package]]
+name = "cc"
+version = "1.0.99"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "96c51067fd44124faa7f870b4b1c969379ad32b2ba805aa959430ceaa384f695"
+dependencies = [
+ "jobserver",
+ "libc",
+ "once_cell",
+]
+
+[[package]]
+name = "cfg-if"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
+
+[[package]]
+name = "convert_case"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e"
+
+[[package]]
+name = "cookie"
+version = "0.16.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e859cd57d0710d9e06c381b550c06e76992472a8c6d527aecd2fc673dcc231fb"
+dependencies = [
+ "percent-encoding",
+ "time",
+ "version_check",
+]
+
+[[package]]
+name = "cpufeatures"
+version = "0.2.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53fe5e26ff1b7aef8bca9c6080520cfb8d9333c7568e1829cef191a9723e5504"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "crc32fast"
+version = "1.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "crypto-common"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3"
+dependencies = [
+ "generic-array",
+ "typenum",
+]
+
+[[package]]
+name = "deranged"
+version = "0.3.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4"
+dependencies = [
+ "powerfmt",
+]
+
+[[package]]
+name = "derive_more"
+version = "0.99.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5f33878137e4dafd7fa914ad4e259e18a4e8e532b9617a2d0150262bf53abfce"
+dependencies = [
+ "convert_case",
+ "proc-macro2",
+ "quote",
+ "rustc_version",
+ "syn",
+]
+
+[[package]]
+name = "digest"
+version = "0.10.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
+dependencies = [
+ "block-buffer",
+ "crypto-common",
+]
+
+[[package]]
+name = "encoding_rs"
+version = "0.8.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b45de904aa0b010bce2ab45264d0631681847fa7b6f2eaa7dab7619943bc4f59"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "env_logger"
+version = "0.9.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a12e6657c4c97ebab115a42dcee77225f7f482cdd841cf7088c657a42e9e00e7"
+dependencies = [
+ "atty",
+ "humantime",
+ "log",
+ "regex",
+ "termcolor",
+]
+
+[[package]]
+name = "equivalent"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5"
+
+[[package]]
+name = "flate2"
+version = "1.0.30"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5f54427cfd1c7829e2a139fcefea601bf088ebca651d2bf53ebc600eac295dae"
+dependencies = [
+ "crc32fast",
+ "miniz_oxide",
+]
+
+[[package]]
+name = "fnv"
+version = "1.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
+
+[[package]]
+name = "foreign-types"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
+dependencies = [
+ "foreign-types-shared",
+]
+
+[[package]]
+name = "foreign-types-shared"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
+
+[[package]]
+name = "form_urlencoded"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456"
+dependencies = [
+ "percent-encoding",
+]
+
+[[package]]
+name = "futures-core"
+version = "0.3.30"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dfc6580bb841c5a68e9ef15c77ccc837b40a7504914d52e47b8b0e9bbda25a1d"
+
+[[package]]
+name = "futures-sink"
+version = "0.3.30"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9fb8e00e87438d937621c1c6269e53f536c14d3fbd6a042bb24879e57d474fb5"
+
+[[package]]
+name = "futures-task"
+version = "0.3.30"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "38d84fa142264698cdce1a9f9172cf383a0c82de1bddcf3092901442c4097004"
+
+[[package]]
+name = "futures-util"
+version = "0.3.30"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3d6401deb83407ab3da39eba7e33987a73c3df0c82b4bb5813ee871c19c41d48"
+dependencies = [
+ "futures-core",
+ "futures-task",
+ "pin-project-lite",
+ "pin-utils",
+]
+
+[[package]]
+name = "generic-array"
+version = "0.14.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
+dependencies = [
+ "typenum",
+ "version_check",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.2.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "wasi",
+]
+
+[[package]]
+name = "gimli"
+version = "0.29.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "40ecd4077b5ae9fd2e9e169b102c6c330d0605168eb0e8bf79952b256dbefffd"
+
+[[package]]
+name = "h2"
+version = "0.3.26"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "81fe527a889e1532da5c525686d96d4c2e74cdd345badf8dfef9f6b39dd5f5e8"
+dependencies = [
+ "bytes",
+ "fnv",
+ "futures-core",
+ "futures-sink",
+ "futures-util",
+ "http",
+ "indexmap",
+ "slab",
+ "tokio",
+ "tokio-util",
+ "tracing",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.14.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
+
+[[package]]
+name = "hermit-abi"
+version = "0.1.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "hermit-abi"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024"
+
+[[package]]
+name = "http"
+version = "0.2.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1"
+dependencies = [
+ "bytes",
+ "fnv",
+ "itoa",
+]
+
+[[package]]
+name = "httparse"
+version = "1.9.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0fcc0b4a115bf80b728eb8ea024ad5bd707b615bfed49e0665b6e0f86fd082d9"
+
+[[package]]
+name = "httpdate"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
+
+[[package]]
+name = "humantime"
+version = "2.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4"
+
+[[package]]
+name = "idna"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "634d9b1461af396cad843f47fdba5597a4f9e6ddd4bfb6ff5d85028c25cb12f6"
+dependencies = [
+ "unicode-bidi",
+ "unicode-normalization",
+]
+
+[[package]]
+name = "impl-more"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "206ca75c9c03ba3d4ace2460e57b189f39f43de612c2f85836e65c929701bb2d"
+
+[[package]]
+name = "indexmap"
+version = "2.2.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "168fb715dda47215e360912c096649d23d58bf392ac62f73919e831745e40f26"
+dependencies = [
+ "equivalent",
+ "hashbrown",
+]
+
+[[package]]
+name = "itoa"
+version = "1.0.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b"
+
+[[package]]
+name = "jobserver"
+version = "0.1.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d2b099aaa34a9751c5bf0878add70444e1ed2dd73f347be99003d4577277de6e"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "language-tags"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d4345964bb142484797b161f473a503a434de77149dd8c7427788c6e13379388"
+
+[[package]]
+name = "libc"
+version = "0.2.155"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c"
+
+[[package]]
+name = "lobby_server"
+version = "0.1.0"
+dependencies = [
+ "actix-web",
+ "env_logger",
+ "log",
+ "openssl",
+ "rand",
+ "serde",
+ "serde_json",
+ "tokio",
+ "uuid",
+]
+
+[[package]]
+name = "local-channel"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6cbc85e69b8df4b8bb8b89ec634e7189099cea8927a276b7384ce5488e53ec8"
+dependencies = [
+ "futures-core",
+ "futures-sink",
+ "local-waker",
+]
+
+[[package]]
+name = "local-waker"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4d873d7c67ce09b42110d801813efbc9364414e356be9935700d368351657487"
+
+[[package]]
+name = "lock_api"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17"
+dependencies = [
+ "autocfg",
+ "scopeguard",
+]
+
+[[package]]
+name = "log"
+version = "0.4.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "90ed8c1e510134f979dbc4f070f87d4313098b704861a105fe34231c70a3901c"
+
+[[package]]
+name = "memchr"
+version = "2.7.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3"
+
+[[package]]
+name = "mime"
+version = "0.3.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
+
+[[package]]
+name = "miniz_oxide"
+version = "0.7.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8a240ddb74feaf34a79a7add65a741f3167852fba007066dcac1ca548d89c08"
+dependencies = [
+ "adler",
+]
+
+[[package]]
+name = "mio"
+version = "0.8.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c"
+dependencies = [
+ "libc",
+ "log",
+ "wasi",
+ "windows-sys 0.48.0",
+]
+
+[[package]]
+name = "num-conv"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
+
+[[package]]
+name = "num_cpus"
+version = "1.16.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43"
+dependencies = [
+ "hermit-abi 0.3.9",
+ "libc",
+]
+
+[[package]]
+name = "object"
+version = "0.36.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "576dfe1fc8f9df304abb159d767a29d0476f7750fbf8aa7ad07816004a207434"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92"
+
+[[package]]
+name = "openssl"
+version = "0.10.64"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "95a0481286a310808298130d22dd1fef0fa571e05a8f44ec801801e84b216b1f"
+dependencies = [
+ "bitflags",
+ "cfg-if",
+ "foreign-types",
+ "libc",
+ "once_cell",
+ "openssl-macros",
+ "openssl-sys",
+]
+
+[[package]]
+name = "openssl-macros"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "openssl-sys"
+version = "0.9.102"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c597637d56fbc83893a35eb0dd04b2b8e7a50c91e64e9493e398b5df4fb45fa2"
+dependencies = [
+ "cc",
+ "libc",
+ "pkg-config",
+ "vcpkg",
+]
+
+[[package]]
+name = "parking_lot"
+version = "0.12.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27"
+dependencies = [
+ "lock_api",
+ "parking_lot_core",
+]
+
+[[package]]
+name = "parking_lot_core"
+version = "0.9.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "redox_syscall",
+ "smallvec",
+ "windows-targets 0.52.5",
+]
+
+[[package]]
+name = "paste"
+version = "1.0.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
+
+[[package]]
+name = "percent-encoding"
+version = "2.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e"
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bda66fc9667c18cb2758a2ac84d1167245054bcf85d5d1aaa6923f45801bdd02"
+
+[[package]]
+name = "pin-utils"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
+
+[[package]]
+name = "pkg-config"
+version = "0.3.30"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d231b230927b5e4ad203db57bbcbee2802f6bce620b1e4a9024a07d94e2907ec"
+
+[[package]]
+name = "powerfmt"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
+
+[[package]]
+name = "ppv-lite86"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de"
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.86"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e719e8df665df0d1c8fbfd238015744736151d4445ec0836b8e628aae103b77"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.36"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "rand"
+version = "0.8.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
+dependencies = [
+ "libc",
+ "rand_chacha",
+ "rand_core",
+]
+
+[[package]]
+name = "rand_chacha"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
+dependencies = [
+ "ppv-lite86",
+ "rand_core",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
+dependencies = [
+ "getrandom",
+]
+
+[[package]]
+name = "redox_syscall"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c82cf8cff14456045f55ec4241383baeff27af886adb72ffb2162f99911de0fd"
+dependencies = [
+ "bitflags",
+]
+
+[[package]]
+name = "regex"
+version = "1.10.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b91213439dad192326a0d7c6ee3955910425f441d7038e0d6933b0aec5c4517f"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-automata",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-automata"
+version = "0.4.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "38caf58cc5ef2fed281f89292ef23f6365465ed9a41b7a7754eb4e26496c92df"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-lite"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53a49587ad06b26609c52e423de037e7f57f20d53535d66e08c695f347df952a"
+
+[[package]]
+name = "regex-syntax"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7a66a03ae7c801facd77a29370b4faec201768915ac14a721ba36f20bc9c209b"
+
+[[package]]
+name = "rustc-demangle"
+version = "0.1.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f"
+
+[[package]]
+name = "rustc_version"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bfa0f585226d2e68097d4f95d113b15b83a82e819ab25717ec0590d9584ef366"
+dependencies = [
+ "semver",
+]
+
+[[package]]
+name = "ryu"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f"
+
+[[package]]
+name = "scopeguard"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+
+[[package]]
+name = "semver"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "61697e0a1c7e512e84a621326239844a24d8207b4669b41bc18b32ea5cbf988b"
+
+[[package]]
+name = "serde"
+version = "1.0.203"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7253ab4de971e72fb7be983802300c30b5a7f0c2e56fab8abfc6a214307c0094"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.203"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "500cbc0ebeb6f46627f50f3f5811ccf6bf00643be300b4c3eabc0ef55dc5b5ba"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.117"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "455182ea6142b14f93f4bc5320a2b31c1f266b66a4a5c858b013302a5d8cbfc3"
+dependencies = [
+ "itoa",
+ "ryu",
+ "serde",
+]
+
+[[package]]
+name = "serde_urlencoded"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
+dependencies = [
+ "form_urlencoded",
+ "itoa",
+ "ryu",
+ "serde",
+]
+
+[[package]]
+name = "sha1"
+version = "0.10.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "digest",
+]
+
+[[package]]
+name = "signal-hook-registry"
+version = "1.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "slab"
+version = "0.4.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "smallvec"
+version = "1.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67"
+
+[[package]]
+name = "socket2"
+version = "0.5.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ce305eb0b4296696835b71df73eb912e0f1ffd2556a501fcede6e0c50349191c"
+dependencies = [
+ "libc",
+ "windows-sys 0.52.0",
+]
+
+[[package]]
+name = "syn"
+version = "2.0.67"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff8655ed1d86f3af4ee3fd3263786bc14245ad17c4c7e85ba7187fb3ae028c90"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "termcolor"
+version = "1.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755"
+dependencies = [
+ "winapi-util",
+]
+
+[[package]]
+name = "time"
+version = "0.3.36"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5dfd88e563464686c916c7e46e623e520ddc6d79fa6641390f2e3fa86e83e885"
+dependencies = [
+ "deranged",
+ "itoa",
+ "num-conv",
+ "powerfmt",
+ "serde",
+ "time-core",
+ "time-macros",
+]
+
+[[package]]
+name = "time-core"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3"
+
+[[package]]
+name = "time-macros"
+version = "0.2.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f252a68540fde3a3877aeea552b832b40ab9a69e318efd078774a01ddee1ccf"
+dependencies = [
+ "num-conv",
+ "time-core",
+]
+
+[[package]]
+name = "tinyvec"
+version = "1.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "87cc5ceb3875bb20c2890005a4e226a4651264a5c75edb2421b52861a0a0cb50"
+dependencies = [
+ "tinyvec_macros",
+]
+
+[[package]]
+name = "tinyvec_macros"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
+
+[[package]]
+name = "tokio"
+version = "1.38.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba4f4a02a7a80d6f274636f0aa95c7e383b912d41fe721a31f29e29698585a4a"
+dependencies = [
+ "backtrace",
+ "bytes",
+ "libc",
+ "mio",
+ "num_cpus",
+ "parking_lot",
+ "pin-project-lite",
+ "signal-hook-registry",
+ "socket2",
+ "tokio-macros",
+ "windows-sys 0.48.0",
+]
+
+[[package]]
+name = "tokio-macros"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5f5ae998a069d4b5aba8ee9dad856af7d520c3699e6159b185c2acd48155d39a"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "tokio-openssl"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ffab79df67727f6acf57f1ff743091873c24c579b1e2ce4d8f53e47ded4d63d"
+dependencies = [
+ "futures-util",
+ "openssl",
+ "openssl-sys",
+ "tokio",
+]
+
+[[package]]
+name = "tokio-util"
+version = "0.7.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9cf6b47b3771c49ac75ad09a6162f53ad4b8088b76ac60e8ec1455b31a189fe1"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "futures-sink",
+ "pin-project-lite",
+ "tokio",
+]
+
+[[package]]
+name = "tracing"
+version = "0.1.40"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef"
+dependencies = [
+ "log",
+ "pin-project-lite",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-core"
+version = "0.1.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54"
+dependencies = [
+ "once_cell",
+]
+
+[[package]]
+name = "typenum"
+version = "1.17.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825"
+
+[[package]]
+name = "unicode-bidi"
+version = "0.3.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "08f95100a766bf4f8f28f90d77e0a5461bbdb219042e7679bebe79004fed8d75"
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b"
+
+[[package]]
+name = "unicode-normalization"
+version = "0.1.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a56d1686db2308d901306f92a263857ef59ea39678a5458e7cb17f01415101f5"
+dependencies = [
+ "tinyvec",
+]
+
+[[package]]
+name = "url"
+version = "2.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "22784dbdf76fdde8af1aeda5622b546b422b6fc585325248a2bf9f5e41e94d6c"
+dependencies = [
+ "form_urlencoded",
+ "idna",
+ "percent-encoding",
+]
+
+[[package]]
+name = "uuid"
+version = "1.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a183cf7feeba97b4dd1c0d46788634f6221d87fa961b305bed08c851829efcc0"
+dependencies = [
+ "getrandom",
+]
+
+[[package]]
+name = "vcpkg"
+version = "0.2.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
+
+[[package]]
+name = "version_check"
+version = "0.9.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f"
+
+[[package]]
+name = "wasi"
+version = "0.11.0+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"
+
+[[package]]
+name = "winapi"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
+dependencies = [
+ "winapi-i686-pc-windows-gnu",
+ "winapi-x86_64-pc-windows-gnu",
+]
+
+[[package]]
+name = "winapi-i686-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
+
+[[package]]
+name = "winapi-util"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4d4cc384e1e73b93bafa6fb4f1df8c41695c8a91cf9c4c64358067d15a7b6c6b"
+dependencies = [
+ "windows-sys 0.52.0",
+]
+
+[[package]]
+name = "winapi-x86_64-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
+
+[[package]]
+name = "windows-sys"
+version = "0.48.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
+dependencies = [
+ "windows-targets 0.48.5",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.52.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
+dependencies = [
+ "windows-targets 0.52.5",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
+dependencies = [
+ "windows_aarch64_gnullvm 0.48.5",
+ "windows_aarch64_msvc 0.48.5",
+ "windows_i686_gnu 0.48.5",
+ "windows_i686_msvc 0.48.5",
+ "windows_x86_64_gnu 0.48.5",
+ "windows_x86_64_gnullvm 0.48.5",
+ "windows_x86_64_msvc 0.48.5",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.52.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6f0713a46559409d202e70e28227288446bf7841d3211583a4b53e3f6d96e7eb"
+dependencies = [
+ "windows_aarch64_gnullvm 0.52.5",
+ "windows_aarch64_msvc 0.52.5",
+ "windows_i686_gnu 0.52.5",
+ "windows_i686_gnullvm",
+ "windows_i686_msvc 0.52.5",
+ "windows_x86_64_gnu 0.52.5",
+ "windows_x86_64_gnullvm 0.52.5",
+ "windows_x86_64_msvc 0.52.5",
+]
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.52.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7088eed71e8b8dda258ecc8bac5fb1153c5cffaf2578fc8ff5d61e23578d3263"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.52.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9985fd1504e250c615ca5f281c3f7a6da76213ebd5ccc9561496568a2752afb6"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.52.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "88ba073cf16d5372720ec942a8ccbf61626074c6d4dd2e745299726ce8b89670"
+
+[[package]]
+name = "windows_i686_gnullvm"
+version = "0.52.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "87f4261229030a858f36b459e748ae97545d6f1ec60e5e0d6a3d32e0dc232ee9"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.52.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db3c2bf3d13d5b658be73463284eaf12830ac9a26a90c717b7f771dfe97487bf"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.52.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4e4246f76bdeff09eb48875a0fd3e2af6aada79d409d33011886d3e1581517d9"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.52.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "852298e482cd67c356ddd9570386e2862b5673c85bd5f88df9ab6802b334c596"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.52.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bec47e5bfd1bff0eeaf6d8b485cc1074891a197ab4225d504cb7a1ab88b02bf0"
+
+[[package]]
+name = "zerocopy"
+version = "0.7.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae87e3fcd617500e5d106f0380cf7b77f3c6092aae37191433159dda23cfb087"
+dependencies = [
+ "zerocopy-derive",
+]
+
+[[package]]
+name = "zerocopy-derive"
+version = "0.7.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "15e934569e47891f7d9411f1a451d947a60e000ab3bd24fbb970f000387d1b3b"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "zstd"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2d789b1514203a1120ad2429eae43a7bd32b90976a7bb8a05f7ec02fa88cc23a"
+dependencies = [
+ "zstd-safe",
+]
+
+[[package]]
+name = "zstd-safe"
+version = "7.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1cd99b45c6bc03a018c8b8a86025678c87e55526064e38f9df301989dce7ec0a"
+dependencies = [
+ "zstd-sys",
+]
+
+[[package]]
+name = "zstd-sys"
+version = "2.0.11+zstd.1.5.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "75652c55c0b6f3e6f12eb786fe1bc960396bf05a1eb3bf1f3691c3610ac2e6d4"
+dependencies = [
+ "cc",
+ "pkg-config",
+]
diff --git a/Lobby Servers/Rust Server/Cargo.toml b/Lobby Servers/Rust Server/Cargo.toml
new file mode 100644
index 00000000..2e80b782
--- /dev/null
+++ b/Lobby Servers/Rust Server/Cargo.toml
@@ -0,0 +1,18 @@
+[package]
+name = "lobby_server"
+version = "0.1.0"
+edition = "2018"
+
+[dependencies]
+actix-web = "4.0"
+tokio = { version = "1", features = ["full"] }
+serde = { version = "1.0", features = ["derive"] }
+serde_json = "1.0"
+log = "0.4"
+env_logger = "0.9"
+uuid = { version = "1.0", features = ["v4"] }
+openssl = "0.10"
+rand = "0.8"
+
+[features]
+default = ["actix-web/openssl"]
\ No newline at end of file
diff --git a/Lobby Servers/Rust Server/Read Me.md b/Lobby Servers/Rust Server/Read Me.md
new file mode 100644
index 00000000..db84e870
--- /dev/null
+++ b/Lobby Servers/Rust Server/Read Me.md
@@ -0,0 +1,56 @@
+# Lobby Server - Rust
+
+This is a [Rust](https://www.rust-lang.org/) implementation of the Derail Valley Lobby Server REST API service. The server can be run in either HTTP or HTTPS (SSL) modes (cert and key PEM files will need to be provided for SSL mode).
+
+## Building the Code
+
+To build the Lobby Server code, you'll need Rust, Cargo and OpenSSL installed on your system.
+
+
+### Installing OpenSSL (Windows)
+OpenSSL can be installed as follows [[source](https://stackoverflow.com/a/70949736)]:
+1. Install OpenSSL from [http://slproweb.com/products/Win32OpenSSL.html](http://slproweb.com/products/Win32OpenSSL.html) into `C:\Program Files\OpenSSL-Win64`
+2. In an elevated terminal
+```
+$env:path = $env:path+ ";C:\Program Files\OpenSSL-Win64\bin"
+cd "C:\Program Files\OpenSSL-Win64"
+mkdir certs
+cd certs
+wget https://curl.se/ca/cacert.pem -o cacert.pem
+```
+4. In the VSCode Rust Server terminal set the following environment variables
+```
+$env:OPENSSL_CONF='C:\Program Files\OpenSSL-Win64\bin\openssl.cfg'
+$env:OPENSSL_NO_VENDOR=1
+$env:RUSTFLAGS='-Ctarget-feature=+crt-static'
+$env:SSL_CERT = 'C:\Program Files\OpenSSL-Win64\certs\cacert.pem'
+$env:OPENSSL_DIR = 'C:\Program Files\OpenSSL-Win64'
+$env:OPENSSL_LIB_DIR = "C:\Program Files\OpenSSL-Win64\lib\VC\x64\MD"
+```
+
+
+### Building
+The code can be built using `cargo build --release` or built and run (for testing purposes) using `cargo run --release`
+
+## Configuration Parameters
+The server can be configured using a `config.json` file; if one is not supplied, the server will create one with the defaults.
+
+Below are the available parameters along with their defaults:
+- `port` (u16): The port number on which the server will listen. Default: `8080`
+- `timeout` (u64): The time-out period in seconds for server removal. Default: `120`
+- `ssl_enabled` (bool): Whether SSL is enabled. Default: `false`
+- `ssl_cert_path` (string): Path to the SSL certificate file. Default: `"cert.pem"`
+- `ssl_key_path` (string): Path to the SSL private key file. Default: `"key.pem"`
+
+To customise these parameters, create a `config.json` file in the project directory with the desired values.
+Example `config.json`:
+```json
+{
+ "port": 8080,
+ "timeout": 120,
+ "ssl_enabled": false,
+ "ssl_cert_path": "cert.pem",
+ "ssl_key_path": "key.pem"
+}
+```
+
diff --git a/Lobby Servers/Rust Server/config.json b/Lobby Servers/Rust Server/config.json
new file mode 100644
index 00000000..e863e8b3
--- /dev/null
+++ b/Lobby Servers/Rust Server/config.json
@@ -0,0 +1,7 @@
+{
+ "port": 8080,
+ "timeout": 120,
+ "ssl_enabled": false,
+ "ssl_cert_path": "cert.pem",
+ "ssl_key_path": "key.pem"
+}
\ No newline at end of file
diff --git a/Lobby Servers/Rust Server/src/config.rs b/Lobby Servers/Rust Server/src/config.rs
new file mode 100644
index 00000000..bc25a1fb
--- /dev/null
+++ b/Lobby Servers/Rust Server/src/config.rs
@@ -0,0 +1,44 @@
+use serde::{Deserialize, Serialize};
+use std::fs::File;
+use std::io::{Read, Write};
+
+#[derive(Serialize, Deserialize, Clone)]
+pub struct Config {
+ pub port: u16,
+ pub timeout: u64,
+ pub ssl_enabled: bool,
+ pub ssl_cert_path: String,
+ pub ssl_key_path: String,
+}
+
+impl Default for Config {
+ fn default() -> Self {
+ Config {
+ port: 8080,
+ timeout: 120,
+ ssl_enabled: false,
+ ssl_cert_path: String::from("cert.pem"),
+ ssl_key_path: String::from("key.pem"),
+ }
+ }
+}
+
+pub fn read_or_create_config() -> Config {
+ let config_path = "config.json";
+ let mut config = Config::default();
+
+ if let Ok(mut file) = File::open(config_path) {
+ let mut contents = String::new();
+ if file.read_to_string(&mut contents).is_ok() {
+ if let Ok(parsed_config) = serde_json::from_str(&contents) {
+ config = parsed_config;
+ }
+ }
+ } else {
+ if let Ok(mut file) = File::create(config_path) {
+ let _ = file.write_all(serde_json::to_string_pretty(&config).unwrap().as_bytes());
+ }
+ }
+
+ config
+}
diff --git a/Lobby Servers/Rust Server/src/handlers.rs b/Lobby Servers/Rust Server/src/handlers.rs
new file mode 100644
index 00000000..36961fdd
--- /dev/null
+++ b/Lobby Servers/Rust Server/src/handlers.rs
@@ -0,0 +1,204 @@
+use actix_web::{web, HttpResponse, HttpRequest, Responder};
+use serde::{Deserialize, Serialize};
+use crate::state::AppState;
+use crate::server::{ServerInfo, PublicServerInfo, AddServerResponse, validate_server_info};
+use crate::utils::generate_private_key;
+use uuid::Uuid;
+
+#[derive(Deserialize)]
+pub struct AddServerRequest {
+ pub port: u16,
+ pub server_name: String,
+ pub password_protected: bool,
+ pub game_mode: u8,
+ pub difficulty: u8,
+ pub time_passed: String,
+ pub current_players: u32,
+ pub max_players: u32,
+ pub required_mods: String,
+ pub game_version: String,
+ pub multiplayer_version: String,
+ pub server_info: String,
+}
+
+pub async fn add_server(data: web::Data, server_info: web::Json, req: HttpRequest) -> impl Responder {
+ let client_ip = req.connection_info().realip_remote_addr().unwrap_or("unknown").to_string();
+
+ let (ipv4, ipv6): (String, String) = match client_ip {
+ IpAddr::V4(ipv4) => (ipv4.to_string(), String::new()), // IPv4 case
+ IpAddr::V6(ipv6) => (String::new(), ipv6.to_string()), // IPv6 case
+ };
+
+ let private_key = generate_private_key(); // Generate a private key
+ let info = ServerInfo {
+ ipv4: ipv4.clone(),
+ ipv6: ipv6.clone(),
+ port: server_info.port,
+ server_name: server_info.server_name.clone(),
+ password_protected: server_info.password_protected,
+ game_mode: server_info.game_mode,
+ difficulty: server_info.difficulty,
+ time_passed: server_info.time_passed.clone(),
+ current_players: server_info.current_players,
+ max_players: server_info.max_players,
+ required_mods: server_info.required_mods.clone(),
+ game_version: server_info.game_version.clone(),
+ multiplayer_version: server_info.multiplayer_version.clone(),
+ server_info: server_info.server_info.clone(),
+ last_update: std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs(),
+ private_key: private_key.clone(),
+ };
+
+ if let Err(e) = validate_server_info(&info) {
+ log::error!("Validation failed: {}", e);
+ return HttpResponse::BadRequest().json(e);
+ }
+
+ let game_server_id = Uuid::new_v4().to_string();
+ let key = game_server_id.clone();
+ let ipv4_request: bool = (ipv4 == String::new());
+
+ match data.servers.lock() {
+ Ok(mut servers) => {
+ servers.insert(key.clone(), info);
+ log::info!("Server added: {}", key);
+ HttpResponse::Ok().json(AddServerResponse { game_server_id: key, private_key, ipv4_request })
+ }
+ Err(_) => {
+ log::error!("Failed to add server: {}", key);
+ HttpResponse::InternalServerError().json("Failed to add server")
+ }
+ }
+}
+
+#[derive(Deserialize)]
+pub struct UpdateServerRequest {
+ pub game_server_id: String,
+ pub private_key: String,
+ pub current_players: u32,
+ pub time_passed: String,
+ pub ipv4: Option,
+}
+
+pub async fn update_server(data: web::Data, server_info: web::Json) -> impl Responder {
+ let mut updated = false;
+ match data.servers.lock() {
+ Ok(mut servers) => {
+ if let Some(info) = servers.get_mut(&server_info.game_server_id) {
+ if info.private_key == server_info.private_key {
+ if server_info.current_players <= info.max_players {
+ info.current_players = server_info.current_players;
+ info.time_passed = server_info.time_passed.clone();
+ info.last_update = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs();
+
+ // Check if ipv4 field is provided, not empty, and valid
+ if let Some(ipv4_str) = &server_info.ipv4 {
+ if !ipv4_str.is_empty() {
+ if let Ok(ip) = ipv4_str.parse::() {
+ if let IpAddr::V4(_) = ip {
+ info.ipv4 = ipv4_str.clone();
+ }
+ }
+ }
+ }
+
+ updated = true;
+ }
+ } else {
+ return HttpResponse::Unauthorized().json("Invalid private key");
+ }
+ }
+ }
+ Err(_) => {
+ log::error!("Failed to update server: {}", server_info.game_server_id);
+ return HttpResponse::InternalServerError().json("Failed to update server");
+ }
+ }
+
+ if updated {
+ log::info!("Server updated: {}", server_info.game_server_id);
+ HttpResponse::Ok().json("Server updated")
+ } else {
+ log::error!("Server not found or invalid current players: {}", server_info.game_server_id);
+ HttpResponse::BadRequest().json("Server not found or invalid current players")
+ }
+}
+
+#[derive(Deserialize)]
+pub struct RemoveServerRequest {
+ pub game_server_id: String,
+ pub private_key: String,
+}
+
+pub async fn remove_server(data: web::Data, server_info: web::Json) -> impl Responder {
+ let mut removed = false;
+ match data.servers.lock() {
+ Ok(mut servers) => {
+ if let Some(info) = servers.get(&server_info.game_server_id) {
+ if info.private_key == server_info.private_key {
+ servers.remove(&server_info.game_server_id);
+ removed = true;
+ } else {
+ return HttpResponse::Unauthorized().json("Invalid private key");
+ }
+ }
+ }
+ Err(_) => {
+ log::error!("Failed to remove server: {}", server_info.game_server_id);
+ return HttpResponse::InternalServerError().json("Failed to remove server");
+ }
+ };
+
+ if removed {
+ log::info!("Server removed: {}", server_info.game_server_id);
+ HttpResponse::Ok().json("Server removed")
+ } else {
+ log::error!("Server not found: {}", server_info.game_server_id);
+ HttpResponse::BadRequest().json("Server not found or invalid private key")
+ }
+}
+
+pub async fn list_servers(data: web::Data, req: HttpRequest) -> impl Responder {
+ let client_ip = req.connection_info().realip_remote_addr().unwrap_or("unknown").to_string();
+
+ let ip_version = match client_ip {
+ IpAddr::V4(_) => "IPv4",
+ IpAddr::V6(_) => "IPv6",
+ };
+
+ match data.servers.lock() {
+ Ok(servers) => {
+ let public_servers: Vec = servers.iter().map(|(id, info)| {
+ let ip = match ip_version {
+ "IPv4" => info.ipv4.clone(),
+ "IPv6" => if info.ipv6 != String::new() {
+ info.ipv6.clone()
+ } else {
+ info.ipv4.clone()
+ },
+ _ => info.ipv4.clone(), // Default to IPv4 if something goes wrong
+ };
+
+ PublicServerInfo {
+ id: id.clone(),
+ ip: ip,
+ port: info.port,
+ server_name: info.server_name.clone(),
+ password_protected: info.password_protected,
+ game_mode: info.game_mode,
+ difficulty: info.difficulty,
+ time_passed: info.time_passed.clone(),
+ current_players: info.current_players,
+ max_players: info.max_players,
+ required_mods: info.required_mods.clone(),
+ game_version: info.game_version.clone(),
+ multiplayer_version: info.multiplayer_version.clone(),
+ server_info: info.server_info.clone(),
+ }
+ }).collect();
+
+ HttpResponse::Ok().json(public_servers)
+ }
+ Err(_) => HttpResponse::InternalServerError().json("Failed to list servers"),
+ }
+}
diff --git a/Lobby Servers/Rust Server/src/main.rs b/Lobby Servers/Rust Server/src/main.rs
new file mode 100644
index 00000000..286a442a
--- /dev/null
+++ b/Lobby Servers/Rust Server/src/main.rs
@@ -0,0 +1,74 @@
+mod config;
+mod server;
+mod state;
+mod handlers;
+mod ssl;
+mod utils;
+
+use crate::config::read_or_create_config;
+use crate::state::AppState;
+use crate::ssl::setup_ssl;
+use actix_web::{web, App, HttpServer};
+use std::sync::{Arc, Mutex};
+use tokio::time::{interval, Duration};
+
+#[tokio::main]
+async fn main() -> std::io::Result<()> {
+ env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
+
+ let config = read_or_create_config();
+ let state = AppState {
+ servers: Arc::new(Mutex::new(std::collections::HashMap::new())),
+ };
+
+ let cleanup_state = state.clone();
+ let config_clone = config.clone();
+
+ tokio::spawn(async move {
+ let mut interval = interval(Duration::from_secs(60));
+ loop {
+ interval.tick().await;
+ let now = std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .unwrap()
+ .as_secs();
+ if let Ok(mut servers) = cleanup_state.servers.lock() {
+ let keys_to_remove: Vec = servers
+ .iter()
+ .filter_map(|(key, info)| {
+ if now - info.last_update > config_clone.timeout {
+ Some(key.clone())
+ } else {
+ None
+ }
+ })
+ .collect();
+ for key in keys_to_remove {
+ servers.remove(&key);
+ }
+ }
+ }
+ });
+
+ let server = {
+ let server_builder = HttpServer::new(move || {
+ App::new()
+ .app_data(web::Data::new(state.clone()))
+ .route("/add_game_server", web::post().to(handlers::add_server))
+ .route("/update_game_server", web::post().to(handlers::update_server))
+ .route("/remove_game_server", web::post().to(handlers::remove_server))
+ .route("/list_game_servers", web::get().to(handlers::list_servers))
+ });
+
+ if config.ssl_enabled {
+ let ssl_builder = setup_ssl(&config)?;
+ server_builder
+ .bind_openssl(format!("0.0.0.0:{}", config.port), (move || ssl_builder)())?
+ } else {
+ server_builder.bind(format!("0.0.0.0:{}", config.port))?
+ }
+ };
+
+ // Start the server
+ server.run().await
+}
\ No newline at end of file
diff --git a/Lobby Servers/Rust Server/src/server.rs b/Lobby Servers/Rust Server/src/server.rs
new file mode 100644
index 00000000..a4c9abc4
--- /dev/null
+++ b/Lobby Servers/Rust Server/src/server.rs
@@ -0,0 +1,64 @@
+use serde::{Deserialize, Serialize};
+
+#[derive(Serialize, Deserialize, Clone)]
+pub struct ServerInfo {
+ pub ipv4: String,
+ pub ipv6: String,
+ pub port: u16,
+ pub server_name: String,
+ pub password_protected: bool,
+ pub game_mode: u8,
+ pub difficulty: u8,
+ pub time_passed: String,
+ pub current_players: u32,
+ pub max_players: u32,
+ pub required_mods: String,
+ pub game_version: String,
+ pub multiplayer_version: String,
+ pub server_info: String,
+ #[serde(skip_serializing)]
+ pub last_update: u64,
+ #[serde(skip_serializing)]
+ pub private_key: String,
+}
+
+#[derive(Serialize, Deserialize, Clone)]
+pub struct PublicServerInfo {
+ pub id: String,
+ pub ip: String,
+ pub port: u16,
+ pub server_name: String,
+ pub password_protected: bool,
+ pub game_mode: u8,
+ pub difficulty: u8,
+ pub time_passed: String,
+ pub current_players: u32,
+ pub max_players: u32,
+ pub required_mods: String,
+ pub game_version: String,
+ pub multiplayer_version: String,
+ pub server_info: String,
+}
+
+#[derive(Serialize, Deserialize, Clone)]
+pub struct AddServerResponse {
+ pub game_server_id: String,
+ pub private_key: String,
+ pub ipv4_request: bool,
+}
+
+pub fn validate_server_info(info: &ServerInfo) -> Result<(), &'static str> {
+ if info.server_name.len() > 25 {
+ return Err("Server name exceeds 25 characters");
+ }
+ if info.server_info.len() > 500 {
+ return Err("Server info exceeds 500 characters");
+ }
+ if info.current_players > info.max_players {
+ return Err("Current players exceed max players");
+ }
+ if info.max_players < 1 {
+ return Err("Max players must be at least 1");
+ }
+ Ok(())
+}
diff --git a/Lobby Servers/Rust Server/src/ssl.rs b/Lobby Servers/Rust Server/src/ssl.rs
new file mode 100644
index 00000000..f8c9f700
--- /dev/null
+++ b/Lobby Servers/Rust Server/src/ssl.rs
@@ -0,0 +1,10 @@
+use crate::config::Config;
+use openssl::ssl::{SslAcceptor, SslFiletype, SslMethod};
+use openssl::ssl::SslAcceptorBuilder;
+
+pub fn setup_ssl(config: &Config) -> std::io::Result {
+ let mut builder = SslAcceptor::mozilla_intermediate(SslMethod::tls())?;
+ builder.set_private_key_file(&config.ssl_key_path, SslFiletype::PEM)?;
+ builder.set_certificate_chain_file(&config.ssl_cert_path)?;
+ Ok(builder)
+}
diff --git a/Lobby Servers/Rust Server/src/state.rs b/Lobby Servers/Rust Server/src/state.rs
new file mode 100644
index 00000000..a1335a90
--- /dev/null
+++ b/Lobby Servers/Rust Server/src/state.rs
@@ -0,0 +1,7 @@
+use std::sync::{Arc, Mutex};
+use crate::server::ServerInfo;
+
+#[derive(Clone)]
+pub struct AppState {
+ pub servers: Arc>>,
+}
diff --git a/Lobby Servers/Rust Server/src/utils.rs b/Lobby Servers/Rust Server/src/utils.rs
new file mode 100644
index 00000000..b89c13c3
--- /dev/null
+++ b/Lobby Servers/Rust Server/src/utils.rs
@@ -0,0 +1,8 @@
+use rand::Rng;
+
+pub fn generate_private_key() -> String {
+ let mut rng = rand::thread_rng();
+ let random_bytes: Vec = (0..16).map(|_| rng.gen::()).collect();
+ let private_key: String = random_bytes.iter().map(|b| format!("{:02x}", b)).collect();
+ private_key
+}
diff --git a/Multiplayer.sln b/Multiplayer.sln
index cb957169..f8f4eef3 100644
--- a/Multiplayer.sln
+++ b/Multiplayer.sln
@@ -1,16 +1,36 @@
-
Microsoft Visual Studio Solution File, Format Version 12.00
-Project("{99D6F2A9-1021-4EA5-ACB3-48CBD6FB8D09}") = "Multiplayer", "Multiplayer/Multiplayer.csproj", "{F712C7FB-EEAE-4036-A938-356E022B0455}"
+# Visual Studio Version 17
+VisualStudioVersion = 17.14.36121.58
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Multiplayer", "Multiplayer\Multiplayer.csproj", "{F712C7FB-EEAE-4036-A938-356E022B0455}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MultiplayerAPI", "MultiplayerAPI\MultiplayerAPI.csproj", "{AB0CA646-6E23-42EC-9F24-176CC0331714}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MultiplayerAPITest", "MultiplayerAPI Tests\MultiplayerAPITest.csproj", "{65E62E81-6FD4-4995-9BF0-3AEF1ED32800}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
- Release|Any CPU = Release|Any CPU
Debug|Any CPU = Debug|Any CPU
+ Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
- {F712C7FB-EEAE-4036-A938-356E022B0455}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {F712C7FB-EEAE-4036-A938-356E022B0455}.Release|Any CPU.Build.0 = Release|Any CPU
{F712C7FB-EEAE-4036-A938-356E022B0455}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{F712C7FB-EEAE-4036-A938-356E022B0455}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {F712C7FB-EEAE-4036-A938-356E022B0455}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {F712C7FB-EEAE-4036-A938-356E022B0455}.Release|Any CPU.Build.0 = Release|Any CPU
+ {AB0CA646-6E23-42EC-9F24-176CC0331714}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {AB0CA646-6E23-42EC-9F24-176CC0331714}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {AB0CA646-6E23-42EC-9F24-176CC0331714}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {AB0CA646-6E23-42EC-9F24-176CC0331714}.Release|Any CPU.Build.0 = Release|Any CPU
+ {65E62E81-6FD4-4995-9BF0-3AEF1ED32800}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {65E62E81-6FD4-4995-9BF0-3AEF1ED32800}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {65E62E81-6FD4-4995-9BF0-3AEF1ED32800}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {65E62E81-6FD4-4995-9BF0-3AEF1ED32800}.Release|Any CPU.Build.0 = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
+ GlobalSection(ExtensibilityGlobals) = postSolution
+ SolutionGuid = {C12E6469-93BC-497D-BEF6-BD6603B6A89C}
EndGlobalSection
EndGlobal
diff --git a/Multiplayer/API/APIProvider.cs b/Multiplayer/API/APIProvider.cs
new file mode 100644
index 00000000..da3a8a2c
--- /dev/null
+++ b/Multiplayer/API/APIProvider.cs
@@ -0,0 +1,142 @@
+using DV.Customization.Paint;
+using DV.Logic.Job;
+using MPAPI.Interfaces;
+using MPAPI.Types;
+using Multiplayer.Components.Networking;
+using Multiplayer.Components.Networking.Train;
+using Multiplayer.Networking.Data;
+using System;
+using System.Collections.Generic;
+
+namespace Multiplayer.API;
+
+public class APIProvider : IMultiplayerAPI
+{
+ internal const string BUILT_AGAINST_API_VERSION = "1.0.0.0";
+
+ public string SupportedApiVersion => BUILT_AGAINST_API_VERSION;
+
+ public string MultiplayerVersion => Multiplayer.Ver;
+
+ public bool IsMultiplayerLoaded => true;
+
+ public bool IsConnected => NetworkLifecycle.Instance.IsClientRunning || NetworkLifecycle.Instance.IsServerRunning;
+
+ public bool IsHost => NetworkLifecycle.Instance.IsHost();
+
+ public bool IsDedicatedServer => false; //feature not implemented
+
+ public bool IsSinglePlayer => NetworkLifecycle.Instance.IsServerRunning && (NetworkLifecycle.Instance?.Server.IsSinglePlayer ?? false);
+
+ public event Action OnTick;
+ public uint TICK_RATE => NetworkLifecycle.TICK_RATE;
+ public uint CurrentTick => NetworkLifecycle.Instance.Tick;
+
+ public bool TryGetNetId(T obj, out ushort netId) where T : class
+ {
+ return NetIdProvider.Instance.TryGetNetId(obj, out netId);
+ }
+
+ public bool TryGetNetId(T obj, out uint netId) where T : class
+ {
+ return NetIdProvider.Instance.TryGetNetId(obj, out netId);
+ }
+
+ public bool TryGetObjectFromNetId(ushort netId, out T obj) where T : class
+ {
+ return NetIdProvider.Instance.TryGetObject(netId, out obj);
+ }
+
+ public bool TryGetObjectFromNetId(uint netId, out T obj) where T : class
+ {
+ return NetIdProvider.Instance.TryGetObject(netId, out obj);
+ }
+
+ public void SetModCompatibility(string modId, MultiplayerCompatibility compatibility)
+ {
+ ModCompatibilityManager.Instance.RegisterCompatibility(modId, compatibility);
+ }
+
+ public uint RegisterPaintTheme(PaintTheme theme)
+ {
+ if (theme == null || string.IsNullOrEmpty(theme.AssetName))
+ {
+ Multiplayer.LogWarning("APIProvider.RegisterPaintTheme() called with null theme or empty AssetName");
+ return 0;
+ }
+
+ if (!NetworkLifecycle.Instance.IsServerRunning || !NetworkLifecycle.Instance.IsClientRunning)
+ {
+ Multiplayer.LogWarning("APIProvider.RegisterPaintTheme() called when server or client is not running");
+ return 0;
+ }
+
+ return PaintThemeLookup.Instance.RegisterTheme(theme);
+ }
+
+ public void UnregisterPaintTheme(PaintTheme theme)
+ {
+ if (theme == null || string.IsNullOrEmpty(theme.AssetName))
+ {
+ Multiplayer.LogWarning("APIProvider.UnregisterPaintTheme() called with null theme or empty AssetName");
+ return;
+ }
+
+ if (!NetworkLifecycle.Instance.IsServerRunning || !NetworkLifecycle.Instance.IsClientRunning)
+ {
+ Multiplayer.LogWarning("APIProvider.UnregisterPaintTheme() called when server or client is not running");
+ return;
+ }
+
+ PaintThemeLookup.Instance.UnregisterTheme(theme);
+ }
+
+ #region Task Serialisation
+ public bool RegisterTaskType(TaskType taskType)
+ where TGameTask : Task
+ where TNetworkData : TaskNetworkData, new()
+ {
+ return TaskNetworkDataFactory.RegisterTaskType(taskType);
+ }
+
+ public bool UnregisterTaskType(TaskType taskType) where TGameTask : Task
+ {
+ return TaskNetworkDataFactory.UnregisterTaskType(taskType);
+ }
+
+ public TaskNetworkData[] ConvertTasks(IEnumerable tasks)
+ {
+ return TaskNetworkDataFactory.ConvertTasks(tasks);
+ }
+
+ public TaskNetworkData ConvertTask(Task task)
+ {
+ return TaskNetworkDataFactory.ConvertTask(task);
+ }
+
+ public TaskNetworkData ConvertTask(TaskType type)
+ {
+ return TaskNetworkDataFactory.ConvertTask(type);
+ }
+
+ #endregion
+
+ #region Class Helpers
+
+ internal APIProvider()
+ {
+ NetworkLifecycle.Instance.OnTick += OnTickInternal;
+ }
+
+ internal void Dispose()
+ {
+ NetworkLifecycle.Instance.OnTick -= OnTickInternal;
+ }
+
+ internal void OnTickInternal(uint tick)
+ {
+ OnTick?.Invoke(tick);
+ }
+
+ #endregion
+}
diff --git a/Multiplayer/API/ClientAPIProvider.cs b/Multiplayer/API/ClientAPIProvider.cs
new file mode 100644
index 00000000..7bbbe4a7
--- /dev/null
+++ b/Multiplayer/API/ClientAPIProvider.cs
@@ -0,0 +1,107 @@
+using MPAPI.Interfaces;
+using MPAPI.Interfaces.Packets;
+using MPAPI.Types;
+using Multiplayer.Components.Networking.Player;
+using Multiplayer.Networking.Managers.Client;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using static UnityModManagerNet.UnityModManager;
+
+namespace Multiplayer.API
+{
+ public class ClientAPIProvider : IClient
+ {
+ private readonly Dictionary _playerWrapperCache = [];
+ private readonly NetworkClient client;
+
+ public event Action OnPlayerConnected;
+ public event Action OnPlayerDisconnected;
+
+ public void RegisterReadyBlock(ModInfo modInfo)
+ {
+ client.RegisterReadyBlock(modInfo.DisplayName);
+ }
+
+ public void CancelReadyBlock(ModInfo modInfo)
+ {
+ client.CancelReadyBlock(modInfo.DisplayName);
+ }
+
+ #region Client Properties
+ public byte PlayerId => client.PlayerId;
+ public IReadOnlyCollection Players => client.ClientPlayerManager.Players.Select(GetWrapper).ToList().AsReadOnly();
+ public int PlayerCount => client.ClientPlayerManager.Players.Count + 1; // add 1 for local player
+
+ public IPlayer GetPlayer(byte playerId)
+ {
+ _playerWrapperCache.TryGetValue(playerId, out var player);
+ return player;
+ }
+
+ public bool IsConnected => client.IsRunning;
+
+ public int Ping => client.Ping;
+
+ #endregion
+
+ #region Packet API
+ public void RegisterPacket(ClientPacketHandler handler) where T : class, IPacket, new()
+ {
+ client.RegisterExternalPacket(handler);
+ }
+ public void RegisterSerializablePacket(ClientPacketHandler handler) where T : class, ISerializablePacket, new()
+ {
+ client.RegisterExternalSerializablePacket(handler);
+ }
+
+ public void SendPacketToServer(T packet, bool reliable = true) where T : class, IPacket, new()
+ {
+ client.SendExternalPacketToServer(packet, reliable);
+ }
+
+ public void SendSerializablePacketToServer(T packet, bool reliable = true) where T : class, ISerializablePacket, new()
+ {
+ client.SendExternalSerializablePacketToServer(packet, reliable);
+ }
+ #endregion
+
+ #region Class Helpers
+ internal ClientAPIProvider(NetworkClient clientInstance)
+ {
+ this.client = clientInstance;
+
+ client.ClientPlayerManager.OnPlayerConnected += OnPlayerConnectedInternal;
+ client.ClientPlayerManager.OnPlayerDisconnected += OnPlayerDisconnectedInternal;
+ }
+
+ internal void Dispose()
+ {
+ client.ClientPlayerManager.OnPlayerConnected -= OnPlayerConnectedInternal;
+ client.ClientPlayerManager.OnPlayerDisconnected -= OnPlayerDisconnectedInternal;
+ }
+
+
+ private ClientPlayerWrapper GetWrapper(NetworkedPlayer networkedPlayer)
+ {
+ if (!_playerWrapperCache.TryGetValue(networkedPlayer.PlayerId, out var wrapper))
+ {
+ wrapper = new ClientPlayerWrapper(networkedPlayer);
+ _playerWrapperCache[networkedPlayer.PlayerId] = wrapper;
+ }
+ return wrapper;
+ }
+
+ private void OnPlayerConnectedInternal(Components.Networking.Player.NetworkedPlayer networkedPlayer)
+ {
+ OnPlayerConnected?.Invoke(GetWrapper(networkedPlayer));
+ }
+
+ private void OnPlayerDisconnectedInternal(Components.Networking.Player.NetworkedPlayer networkedPlayer)
+ {
+ OnPlayerDisconnected?.Invoke(GetWrapper(networkedPlayer));
+ _playerWrapperCache.Remove(networkedPlayer.PlayerId);
+ }
+ #endregion
+ }
+}
diff --git a/Multiplayer/API/ClientPlayerWrapper.cs b/Multiplayer/API/ClientPlayerWrapper.cs
new file mode 100644
index 00000000..7cd79f2c
--- /dev/null
+++ b/Multiplayer/API/ClientPlayerWrapper.cs
@@ -0,0 +1,31 @@
+using MPAPI.Interfaces;
+using Multiplayer.Components.Networking.Player;
+using UnityEngine;
+
+namespace Multiplayer.API;
+
+public class ClientPlayerWrapper : IPlayer
+{
+ private readonly NetworkedPlayer _networkedPlayer;
+ private readonly bool _isHost;
+
+ public ClientPlayerWrapper(NetworkedPlayer networkedPlayer, bool isHost = false)
+ {
+ _networkedPlayer = networkedPlayer;
+ _isHost = isHost;
+ }
+
+ public byte PlayerId => _networkedPlayer.PlayerId;
+ public string Username
+ {
+ get => _networkedPlayer.Username;
+ set => _networkedPlayer.Username = value;
+ }
+ public Vector3 Position => _networkedPlayer.transform.position;
+ public float RotationY => _networkedPlayer.transform.rotation.eulerAngles.y;
+ public bool IsLoaded => true; // If we have the object, it's loaded
+ public bool IsHost => _isHost;
+ public int Ping => _networkedPlayer.GetPing();
+ public bool IsOnCar => _networkedPlayer.IsOnCar;
+ public TrainCar OccupiedCar => _networkedPlayer.OccupiedCar;
+}
diff --git a/Multiplayer/API/ExternalSerializablePacketWrapper.cs b/Multiplayer/API/ExternalSerializablePacketWrapper.cs
new file mode 100644
index 00000000..f5b7d404
--- /dev/null
+++ b/Multiplayer/API/ExternalSerializablePacketWrapper.cs
@@ -0,0 +1,56 @@
+using LiteNetLib.Utils;
+using MPAPI.Interfaces.Packets;
+using System.IO;
+
+namespace Multiplayer.API;
+
+///
+/// Wrapper for external serializable packets to integrate with LiteNetLib
+///
+/// The packet type
+public class ExternalSerializablePacketWrapper : INetSerializable where T : class, ISerializablePacket, new()
+{
+ const int COMPRESSION_THRESHOLD = 1024;
+
+ public T Packet { get; set; }
+
+ public void Serialize(NetDataWriter writer)
+ {
+ byte[] data;
+
+ using var memoryStream = new MemoryStream();
+ using var binaryWriter = new BinaryWriter(memoryStream);
+
+ Packet.Serialize(binaryWriter);
+
+ data = memoryStream.ToArray();
+
+ bool shouldCompress = memoryStream.Length >= COMPRESSION_THRESHOLD;
+ writer.Put(shouldCompress);
+
+ if (shouldCompress)
+ {
+ var lenBefore = data.Length;
+ data = PacketCompression.Compress(data);
+
+ Multiplayer.LogDebug(() => $"ExternalSerializablePacketWrapper<{typeof(T).Name}>: Compressed {lenBefore} to {data.Length} bytes");
+ }
+
+ writer.PutBytesWithLength(data);
+ }
+
+ public void Deserialize(NetDataReader reader)
+ {
+ bool isCompressed = reader.GetBool();
+ var data = reader.GetBytesWithLength();
+
+ if (isCompressed)
+ data = PacketCompression.Decompress(data);
+
+ using var memoryStream = new MemoryStream(data);
+ using var binaryReader = new BinaryReader(memoryStream);
+
+ Packet = new T();
+ Packet.Deserialize(binaryReader);
+ }
+}
diff --git a/Multiplayer/API/ModCompatibilityManager.cs b/Multiplayer/API/ModCompatibilityManager.cs
new file mode 100644
index 00000000..ce14ed01
--- /dev/null
+++ b/Multiplayer/API/ModCompatibilityManager.cs
@@ -0,0 +1,201 @@
+using DV.JObjectExtstensions;
+using DV.Utils;
+using JetBrains.Annotations;
+using MPAPI.Types;
+using Multiplayer.Components.MainMenu;
+using Multiplayer.Networking.Data;
+using Newtonsoft.Json.Linq;
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using UnityModManagerNet;
+
+namespace Multiplayer.API;
+
+public class ModCompatibilityManager : SingletonBehaviour
+{
+ private const string JSON_FILE = "info.json";
+ private const string JSON_MP_COMPAT_KEY = "MultiplayerCompatibility";
+ private static readonly Dictionary _modCompatibility = [];
+
+ protected override void Awake()
+ {
+ base.Awake();
+
+ DontDestroyOnLoad(this);
+
+ //Register ourselves
+ RegisterCompatibility("Multiplayer", MultiplayerCompatibility.All);
+
+ //we don't care if the client does/doesn't have these mods
+ RegisterCompatibility("RuntimeUnityEditor", MultiplayerCompatibility.Client);
+ RegisterCompatibility("BookletOrganizer", MultiplayerCompatibility.Host);
+ RegisterCompatibility("RemoteDispatch", MultiplayerCompatibility.Client);
+ RegisterCompatibility("CommsRadioAPI", MultiplayerCompatibility.Client);
+ RegisterCompatibility("DVCustomCargo", MultiplayerCompatibility.All);
+ RegisterCompatibility("DVDiscordPresenceMod", MultiplayerCompatibility.Client);
+ RegisterCompatibility("DVLangHelper", MultiplayerCompatibility.Client);
+ RegisterCompatibility("LightingOverhaul", MultiplayerCompatibility.Client);
+ RegisterCompatibility("dv-improved-job-overview", MultiplayerCompatibility.Client);
+ RegisterCompatibility("dv_f_spammer", MultiplayerCompatibility.Client);
+
+ //Json entries will override hardcoded entries
+ ReadModJsons();
+
+ //Hardcoded and json entries will be overridden by API calls
+ }
+
+ public void RegisterCompatibility(string modId, MultiplayerCompatibility compatibility)
+ {
+ Multiplayer.LogDebug(() => $"RegisterCompatibility({modId}, {compatibility})");
+
+ if (!string.IsNullOrEmpty(modId))
+ _modCompatibility[modId] = compatibility;
+ }
+
+ private void ReadModJsons()
+ {
+ foreach (var modEntry in UnityModManager.modEntries)
+ {
+ var jsonPath = Path.Combine(modEntry.Path, JSON_FILE);
+
+ if (File.Exists(jsonPath))
+ {
+ try
+ {
+ var json = File.ReadAllText(jsonPath);
+ var jobj = JObject.Parse(json);
+ var compatStr = jobj.GetString(JSON_MP_COMPAT_KEY);
+
+ var parsed = Enum.TryParse(compatStr, out MultiplayerCompatibility compatibility);
+
+ Multiplayer.LogDebug(() => $"Mod '{modEntry.Info.DisplayName}' ({modEntry.Info.Id}) has MP mod compatibility entry \'{compatStr}\', parses to: {compatibility}");
+
+ if (parsed)
+ RegisterCompatibility(modEntry.Info.Id, compatibility);
+ }
+ catch (Exception e)
+ {
+ Multiplayer.LogException($"Failed to parse mod entry {modEntry.Info.Id}", e);
+ }
+ }
+ else
+ {
+ Multiplayer.LogWarning($"No json found for {modEntry.Info.Id}");
+ }
+ }
+ }
+
+ public bool TryGetCompatibility(string modId, out MultiplayerCompatibility compatibility)
+ {
+ return _modCompatibility.TryGetValue(modId, out compatibility);
+ }
+
+ public MultiplayerCompatibility GetCompatibility(ModInfo mod)
+ {
+ if (TryGetCompatibility(mod.Id, out var compatibility))
+ return compatibility;
+
+ return MultiplayerCompatibility.Undefined;
+ }
+
+ public ModValidationResult ValidateClientMods(ModInfo[] clientMods)
+ {
+ var localMods = GetLocalMods();
+ var localModIds = localMods.Select(l => l.Id);
+
+ var clientModIds = clientMods.Select(c => c.Id);
+
+ List missing = clientMods.Where(c => !localModIds.Contains(c.Id)).ToList();
+ List extra = localMods.Where(l => !clientModIds.Contains(l.Id)).ToList();
+
+ bool valid = (missing.Count == 0) && (extra.Count == 0);
+
+ return new()
+ {
+ IsValid = valid,
+ Missing = missing,
+ Extra = extra
+ };
+ }
+
+ ///
+ /// Checks if any incompatible mods are enabled and generates an message box to alert the user
+ ///
+ /// True if incompatible mods have been found
+ public bool CheckModCompatibility()
+ {
+ List incompatible = [];
+
+ foreach (var modInfo in ModInfo.FromModEntries(UnityModManager.modEntries))
+ {
+ if (TryGetCompatibility(modInfo.Id, out var compatibility))
+ {
+ if (compatibility == MultiplayerCompatibility.Incompatible)
+ incompatible.Add(modInfo.Id);
+ }
+ }
+
+ if (incompatible.Count == 0)
+ return false;
+
+ var message = $"{Locale.MAIN_MENU__INCOMPATIBLE_MODS} {string.Join(", ", incompatible)}";
+
+ MainMenuThingsAndStuff.Instance.ShowOkPopup(message, () => { });
+
+ return true;
+ }
+
+ // Returns a list of mods installed and enabled, filtered for mods that are required for hosts and clients
+ public ModInfo[] GetLocalMods()
+ {
+ List localMods = [];
+
+ foreach (var modInfo in ModInfo.FromModEntries(UnityModManager.modEntries))
+ {
+ if (TryGetCompatibility(modInfo.Id, out var compatibility))
+ {
+ // Only include mods that are relevant for client validation
+ switch (compatibility)
+ {
+ case MultiplayerCompatibility.Undefined:
+ case MultiplayerCompatibility.All:
+ //undefined and "All" mods are required by all clients
+ localMods.Add(modInfo);
+ break;
+
+ case MultiplayerCompatibility.Incompatible:
+ //There shouldn't be any at this stage
+ localMods.Add(modInfo);
+ break;
+
+ case MultiplayerCompatibility.Host:
+ case MultiplayerCompatibility.Client:
+ // Not required, should have no impact on game play
+ break;
+ }
+ }
+ else
+ {
+ // No compatibility info - include for validation (safe default)
+ Multiplayer.LogWarning($"No compatibility info for mod {modInfo.Id}, including in validation");
+ localMods.Add(modInfo);
+ }
+ }
+ return localMods.ToArray();
+ }
+
+ [UsedImplicitly]
+ public new static string AllowAutoCreate()
+ {
+ return $"[{nameof(ModCompatibilityManager)}]";
+ }
+}
+
+public class ModValidationResult
+{
+ public bool IsValid { get; set; }
+ public List Missing { get; set; } = [];
+ public List Extra { get; set; } = [];
+}
diff --git a/Multiplayer/API/NetIdProvider.cs b/Multiplayer/API/NetIdProvider.cs
new file mode 100644
index 00000000..9e5e3043
--- /dev/null
+++ b/Multiplayer/API/NetIdProvider.cs
@@ -0,0 +1,119 @@
+using DV.CabControls;
+using DV.Customization.Paint;
+using DV.Logic.Job;
+using DV.ThingTypes;
+using DV.Utils;
+using JetBrains.Annotations;
+using MPAPI.Interfaces;
+using Multiplayer.Components.Networking.Jobs;
+using Multiplayer.Components.Networking.Train;
+using Multiplayer.Components.Networking.World;
+using System;
+using System.Collections.Generic;
+
+namespace Multiplayer.API;
+
+public delegate bool TryGetNetIdDelegate(T obj, out ushort netId) where T : class;
+public delegate bool TryGetObjectDelegate(ushort netId, out T obj) where T : class;
+
+public delegate bool TryGetUIntNetIdDelegate(T obj, out uint netId) where T : class;
+public delegate bool TryGetObjectUIntDelegate(uint netId, out T obj) where T : class;
+
+internal class NetIdProvider : SingletonBehaviour, INetIdProvider
+{
+ private readonly Dictionary handlers = [];
+
+ protected override void Awake()
+ {
+ base.Awake();
+ RegisterHandler(NetworkedTrainCar.TryGetNetId, NetworkedTrainCar.TryGet);
+ RegisterHandler(NetworkedTrainCar.TryGetNetId, NetworkedTrainCar.TryGet);
+
+ RegisterHandler(CargoTypeLookup.Instance.TryGetNetId, CargoTypeLookup.Instance.TryGet);
+ RegisterHandler(PaintThemeLookup.Instance.TryGetNetId, PaintThemeLookup.Instance.TryGet);
+
+ RegisterHandler(NetworkedJunction.TryGetNetId, NetworkedJunction.TryGet);
+ RegisterHandler(NetworkedTurntable.TryGetNetId, NetworkedTurntable.TryGet);
+ RegisterHandler(NetworkedRailTrack.TryGetNetId, NetworkedRailTrack.TryGet);
+
+ RegisterHandler(NetworkedStationController.TryGetNetId, NetworkedStationController.TryGet);
+ RegisterHandler(NetworkedStationController.TryGetNetId, NetworkedStationController.TryGet);
+ RegisterHandler(NetworkedStationController.TryGetNetId, NetworkedStationController.TryGet);
+
+ RegisterHandler(WarehouseMachineLookup.TryGetNetId, WarehouseMachineLookup.TryGet);
+ RegisterHandler(NetworkedWarehouseMachineController.TryGetNetId, NetworkedWarehouseMachineController.TryGet);
+
+ RegisterHandler(NetworkedJob.TryGetNetId, NetworkedJob.TryGetJob);
+ RegisterHandler(NetworkedTask.TryGetNetId, NetworkedTask.TryGet);
+
+ RegisterHandler(NetworkedItem.TryGetNetId, NetworkedItem.GetItem);
+ }
+
+ public void RegisterHandler(TryGetNetIdDelegate tryGetNetId, TryGetObjectDelegate tryGetObject) where T : class
+ {
+ handlers[typeof(T)] = (tryGetNetId, tryGetObject);
+ }
+
+ public void RegisterHandler(TryGetUIntNetIdDelegate tryGetNetId, TryGetObjectUIntDelegate tryGetObject) where T : class
+ {
+ handlers[typeof(T)] = (tryGetNetId, tryGetObject);
+ }
+
+ public bool TryGetNetId(T obj, out ushort netId) where T : class
+ {
+ netId = 0;
+
+ if (obj == null)
+ return false;
+
+ if (handlers.TryGetValue(typeof(T), out var handler) && handler is (TryGetNetIdDelegate tryGetNetId, TryGetObjectDelegate _))
+ return tryGetNetId(obj, out netId);
+
+ return false;
+ }
+
+ public bool TryGetObject(ushort netId, out T obj) where T : class
+ {
+ obj = null;
+
+ if (netId == 0)
+ return false;
+
+ if (handlers.TryGetValue(typeof(T), out var handler) && handler is (TryGetNetIdDelegate _, TryGetObjectDelegate tryGetObject))
+ return tryGetObject(netId, out obj);
+
+ return false;
+ }
+
+ public bool TryGetNetId(T obj, out uint netId) where T : class
+ {
+ netId = 0;
+
+ if (obj == null)
+ return false;
+
+ if (handlers.TryGetValue(typeof(T), out var handler) && handler is (TryGetUIntNetIdDelegate tryGetNetId, TryGetObjectUIntDelegate _))
+ return tryGetNetId(obj, out netId);
+
+ return false;
+ }
+
+ public bool TryGetObject(uint netId, out T obj) where T : class
+ {
+ obj = null;
+
+ if (netId == 0)
+ return false;
+
+ if (handlers.TryGetValue(typeof(T), out var handler) && handler is (TryGetUIntNetIdDelegate _, TryGetObjectUIntDelegate tryGetObject))
+ return tryGetObject(netId, out obj);
+
+ return false;
+ }
+
+ [UsedImplicitly]
+ public new static string AllowAutoCreate()
+ {
+ return $"[{nameof(NetIdProvider)}]";
+ }
+}
diff --git a/Multiplayer/API/ServerAPIProvider.cs b/Multiplayer/API/ServerAPIProvider.cs
new file mode 100644
index 00000000..cc4580f6
--- /dev/null
+++ b/Multiplayer/API/ServerAPIProvider.cs
@@ -0,0 +1,200 @@
+using MPAPI.Interfaces;
+using MPAPI.Interfaces.Packets;
+using MPAPI.Types;
+using Multiplayer.Networking.Data;
+using Multiplayer.Networking.Managers.Server;
+using Multiplayer.Networking.TransportLayers;
+using Multiplayer.Utils;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using UnityEngine;
+
+namespace Multiplayer.API;
+
+public class ServerAPIProvider : IServer
+{
+ private readonly Dictionary _playerWrapperCache = [];
+ private readonly NetworkServer server;
+
+ public event Action OnPlayerConnected;
+ public event Action OnPlayerDisconnected;
+ public event Action OnPlayerReady;
+
+ #region Server Properties
+
+ public int PlayerCount => server.PlayerCount;
+
+ public IReadOnlyCollection Players => server.ServerPlayers.Select(GetWrapper).ToList().AsReadOnly();
+
+ public IPlayer GetPlayer(byte PlayerId)
+ {
+ _playerWrapperCache.TryGetValue(PlayerId, out var player);
+ return player;
+ }
+ #endregion
+
+ #region Packet API
+ public void RegisterPacket(ServerPacketHandler handler) where T : class, IPacket, new()
+ {
+ server.RegisterExternalPacket(handler);
+ }
+ public void RegisterSerializablePacket(ServerPacketHandler handler) where T : class, ISerializablePacket, new()
+ {
+ server.RegisterExternalSerializablePacket(handler);
+ }
+
+
+ public void SendPacketToAll(T packet, bool reliable = true, bool excludeSelf = false, IPlayer excludePlayer = null) where T : class, IPacket, new()
+ {
+ ITransportPeer peer = null;
+
+ if (excludePlayer != null)
+ peer = GetPeerFromPlayer(excludePlayer, $"SendPacketToAll<{typeof(T).Name}>");
+
+ server.SendExternalPacketToAll(packet, reliable, peer, excludeSelf);
+ }
+
+ public void SendSerializablePacketToAll(T packet, bool reliable = true, bool excludeSelf = false, IPlayer excludePlayer = null) where T : class, ISerializablePacket, new()
+ {
+ ITransportPeer peer = null;
+
+ if(excludePlayer != null)
+ peer = GetPeerFromPlayer(excludePlayer, $"SendSerializablePacketToAll<{typeof(T).Name}>");
+
+ server.SendExternalSerializablePacketToAll(packet, reliable, peer, excludeSelf);
+ }
+
+ public void SendPacketToPlayer(T packet, IPlayer player, bool reliable = true) where T : class, IPacket, new()
+ {
+ var peer = GetPeerFromPlayer(player, $"SendPacketToPlayer<{typeof(T).Name}>");
+
+ if (peer != null)
+ server.SendExternalPacketToPlayer(packet, peer, reliable);
+ }
+
+ public void SendSerializablePacketToPlayer(T packet, IPlayer player, bool reliable = true) where T : class, ISerializablePacket, new()
+ {
+ var peer = GetPeerFromPlayer(player, $"SendSerializablePacketToPlayer<{typeof(T).Name}>");
+
+ if (peer != null)
+ server.SendExternalSerializablePacketToPlayer(packet, peer, reliable);
+ }
+ #endregion
+
+ #region Server Util
+ public float AnyPlayerSqrMag(GameObject item) => DvExtensions.AnyPlayerSqrMag(item);
+
+ public float AnyPlayerSqrMag(Vector3 anchor) => DvExtensions.AnyPlayerSqrMag(anchor);
+ #endregion
+
+ #region Chat
+ public void SendServerChatMessage(string message, IPlayer excludePlayer = null)
+ {
+ var excludedServerPlayer = GetServerPlayerFromIPlayer(excludePlayer);
+ if (excludedServerPlayer != null)
+ server.ChatManager.ServerMessage(message, null, excludedServerPlayer);
+ }
+
+ public void SendWhisperChatMessage(string message, IPlayer player)
+ {
+ var serverPlayer = GetServerPlayerFromIPlayer(player);
+ if (serverPlayer != null)
+ server.SendWhisper(message, serverPlayer);
+ }
+
+ public bool RegisterChatCommand(string commandLong, string commandShort, Func helpMessage, ChatCommandCallback callback)
+ {
+ ChatCommandCallbackInternal internalCallback = (message, serverPlayer) =>
+ {
+ var playerWrapper = GetWrapper(serverPlayer);
+ callback(message, playerWrapper);
+ };
+
+ return server.ChatManager.RegisterChatCommand(commandLong, commandShort, helpMessage, internalCallback);
+ }
+
+ public void RegisterChatFilter(ChatFilterDelegate callback)
+ {
+ ChatFilterDelegateInternal internalCallback = (ref string message, ServerPlayer serverPlayer) =>
+ {
+ var playerWrapper = GetWrapper(serverPlayer);
+ return callback(ref message, playerWrapper);
+ };
+
+ server.ChatManager.RegisterChatFilter(internalCallback);
+ }
+ #endregion
+
+ #region Class Helpers
+ internal ServerAPIProvider(NetworkServer serverInstance)
+ {
+ this.server = serverInstance;
+
+ server.PlayerConnected += OnPlayerConnectedInternal;
+ server.PlayerDisconnected += OnPlayerDisconnectedInternal;
+ server.PlayerReady += OnPlayerReadyInternal;
+ }
+
+ private ServerPlayerWrapper GetWrapper(ServerPlayer serverPlayer)
+ {
+ if (!_playerWrapperCache.TryGetValue(serverPlayer.PlayerId, out var wrapper))
+ {
+ wrapper = new ServerPlayerWrapper(serverPlayer);
+ _playerWrapperCache[serverPlayer.PlayerId] = wrapper;
+ }
+ return wrapper;
+ }
+
+ private ITransportPeer GetPeerFromPlayer(IPlayer player, string operationName)
+ {
+ if (player == null)
+ {
+ server.LogDebug(() => $"{operationName}: Player is null");
+ return null;
+ }
+
+ if (player is ServerPlayerWrapper playerWrapper)
+ {
+ return playerWrapper.Peer;
+ }
+
+ server.LogWarning($"{operationName}: Player '{player.Username}' is not a ServerPlayerWrapper (got {player.GetType().Name})");
+ return null;
+ }
+
+ private ServerPlayer GetServerPlayerFromIPlayer(IPlayer player)
+ {
+ if (player == null)
+ return null;
+
+ if (player is ServerPlayerWrapper wrapper)
+ return wrapper._serverPlayer;
+
+ server.LogWarning($"GetServerPlayerFromIPlayer: Player '{player.Username}' is not a ServerPlayerWrapper (got {player.GetType().Name})");
+ return null;
+ }
+
+ internal void Dispose()
+ {
+ server.PlayerConnected -= OnPlayerConnectedInternal;
+ server.PlayerDisconnected -= OnPlayerDisconnectedInternal;
+ }
+
+ private void OnPlayerConnectedInternal(ServerPlayer serverPlayer)
+ {
+ OnPlayerConnected?.Invoke(GetWrapper(serverPlayer));
+ }
+
+ private void OnPlayerDisconnectedInternal(ServerPlayer serverPlayer)
+ {
+ OnPlayerDisconnected?.Invoke(GetWrapper(serverPlayer));
+ _playerWrapperCache.Remove(serverPlayer.PlayerId);
+ }
+
+ private void OnPlayerReadyInternal(ServerPlayer serverPlayer)
+ {
+ OnPlayerReady?.Invoke(GetWrapper(serverPlayer));
+ }
+ #endregion
+}
diff --git a/Multiplayer/API/ServerPlayerWrapper.cs b/Multiplayer/API/ServerPlayerWrapper.cs
new file mode 100644
index 00000000..93eeb173
--- /dev/null
+++ b/Multiplayer/API/ServerPlayerWrapper.cs
@@ -0,0 +1,44 @@
+using MPAPI.Interfaces;
+using Multiplayer.Components.Networking;
+using Multiplayer.Components.Networking.Train;
+using Multiplayer.Networking.Data;
+using Multiplayer.Networking.TransportLayers;
+using UnityEngine;
+
+namespace Multiplayer.API;
+
+public class ServerPlayerWrapper : IPlayer
+{
+ internal readonly ServerPlayer _serverPlayer;
+ private readonly bool _isHost;
+
+ public ServerPlayerWrapper(ServerPlayer serverPlayer)
+ {
+ _serverPlayer = serverPlayer;
+ _isHost = NetworkLifecycle.Instance?.IsHost(serverPlayer) ?? false;
+ }
+
+ public byte PlayerId => _serverPlayer.PlayerId;
+
+ public string Username
+ {
+ get => _serverPlayer.Username;
+ set => _serverPlayer.Username = value;
+ }
+
+ public Vector3 Position => _serverPlayer.WorldPosition;
+ public float RotationY => _serverPlayer.WorldRotationY;
+ public bool IsLoaded => _serverPlayer.IsLoaded;
+ public bool IsHost => _isHost;
+ public int Ping => 0; // Server doesn't track ping for players
+ public bool IsOnCar => _serverPlayer.CarId != 0;
+ public TrainCar OccupiedCar => GetOccupiedCar();
+
+ internal TrainCar GetOccupiedCar()
+ {
+ NetworkedTrainCar.TryGet(_serverPlayer.CarId, out TrainCar trainCar);
+ return trainCar;
+ }
+
+ internal ITransportPeer Peer => _serverPlayer.Peer;
+}
diff --git a/Multiplayer/Components/IdMonoBehaviour.cs b/Multiplayer/Components/IdMonoBehaviour.cs
index f8fa3c6a..732e4339 100644
--- a/Multiplayer/Components/IdMonoBehaviour.cs
+++ b/Multiplayer/Components/IdMonoBehaviour.cs
@@ -1,5 +1,4 @@
using System.Collections.Generic;
-using LiteNetLib.Utils;
using Multiplayer.Components.Networking;
using Multiplayer.Utils;
using UnityEngine;
@@ -9,7 +8,7 @@ namespace Multiplayer.Components;
public abstract class IdMonoBehaviour : MonoBehaviour where T : struct where I : MonoBehaviour
{
private static readonly IdPool idPool = new();
- private static readonly Dictionary> indexToObject = new();
+ private static readonly Dictionary> indexToObject = [];
private T _netId;
@@ -32,7 +31,16 @@ protected static bool Get(T netId, out IdMonoBehaviour obj)
return true;
obj = null;
if ((netId as dynamic).CompareTo(default(T)) != 0)
- Multiplayer.LogDebug(() => $"Got invalid NetId {netId} while processing packet {NetPacketProcessor.CurrentlyProcessingPacket}");
+ Multiplayer.LogDebug(() => $"Got invalid NetId {netId} while processing packet {NetworkLifecycle.Instance.IsProcessingPacket}");
+ return false;
+ }
+
+ protected static bool TryGet(T netId, out IdMonoBehaviour obj)
+ {
+ if (indexToObject.TryGetValue(netId, out obj))
+ return true;
+
+ obj = null;
return false;
}
diff --git a/Multiplayer/Components/MainMenu/HostGamePane.cs b/Multiplayer/Components/MainMenu/HostGamePane.cs
new file mode 100644
index 00000000..1967d507
--- /dev/null
+++ b/Multiplayer/Components/MainMenu/HostGamePane.cs
@@ -0,0 +1,485 @@
+using DV;
+using DV.Common;
+using DV.Localization;
+using DV.Platform.Steam;
+using DV.UI;
+using DV.UI.PresetEditors;
+using DV.UIFramework;
+using Multiplayer.API;
+using Multiplayer.Components.Networking;
+using Multiplayer.Components.Util;
+using Multiplayer.Networking.Data;
+using Multiplayer.Patches.MainMenu;
+using Multiplayer.Utils;
+using System;
+using System.Linq;
+using System.Reflection;
+using TMPro;
+using UnityEngine;
+using UnityEngine.Events;
+using UnityEngine.UI;
+
+namespace Multiplayer.Components.MainMenu;
+
+public class HostGamePane : MonoBehaviour
+{
+ private const int MAX_SERVER_NAME_LEN = 25;
+ private const int MAX_PORT_LEN = 5;
+ private const int MAX_DETAILS_LEN = 500;
+
+ private const int MIN_PORT = 1024;
+ private const int MAX_PORT = 49151;
+ private const int MIN_PLAYERS = 2;
+ private const int MAX_PLAYERS = 10;
+
+ private const int DEFAULT_PORT = 7777;
+
+ TMP_InputField serverName;
+ TMP_InputField password;
+ TMP_InputField port;
+ TMP_InputField details;
+ TextMeshProUGUI serverDetails;
+
+ SliderDV maxPlayers;
+ Selector gameVisibility;
+ ButtonDV startButton;
+
+ public ISaveGame saveGame;
+ public UIStartGameData startGameData;
+ public AUserProfileProvider userProvider;
+ public AScenarioProvider scenarioProvider;
+ LauncherController lcInstance;
+
+ public Action continueCareerRequested;
+
+ private bool incompatibleMods = true;
+ #region setup
+
+ public void Awake()
+ {
+ Multiplayer.Log("HostGamePane Awake()");
+
+ CleanUI();
+ BuildUI();
+ ValidateInputs(null);
+ }
+
+ public void Start()
+ {
+ Multiplayer.Log("HostGamePane Started");
+
+ if (DVSteamworks.Success)
+ return;
+
+ Multiplayer.Log($"Steam not detected, prompt for restart.");
+ MainMenuThingsAndStuff.Instance.ShowOkPopup("Steam not detected. Please restart the game with Steam running", () => { });
+ }
+
+ public void OnEnable()
+ {
+ this.SetupListeners(true);
+
+ incompatibleMods = ModCompatibilityManager.Instance.CheckModCompatibility();
+ ValidateInputs(null);
+ }
+
+ // Disable listeners
+ public void OnDisable()
+ {
+ this.SetupListeners(false);
+ }
+
+ private void CleanUI()
+ {
+ //top elements
+ GameObject.Destroy(this.FindChildByName("Text Content"));
+
+ //body elements
+ GameObject.Destroy(this.FindChildByName("GRID VIEW"));
+ GameObject.Destroy(this.FindChildByName("HardcoreSavingBanner"));
+ GameObject.Destroy(this.FindChildByName("TutorialSavingBanner"));
+
+ //footer elements
+ GameObject.Destroy(this.FindChildByName("ButtonIcon OpenFolder"));
+ GameObject.Destroy(this.FindChildByName("ButtonIcon Rename"));
+ GameObject.Destroy(this.FindChildByName("ButtonIcon Delete"));
+ GameObject.Destroy(this.FindChildByName("ButtonTextIcon Load"));
+ GameObject.Destroy(this.FindChildByName("ButtonTextIcon Overwrite"));
+
+ }
+ private void BuildUI()
+ {
+ //Create Prefabs
+ GameObject goMMC = GameObject.FindObjectOfType().gameObject;
+
+ GameObject dividerPrefab = goMMC.FindChildByName("Divider");
+ if (dividerPrefab == null)
+ {
+ Multiplayer.LogError("Divider not found!");
+ return;
+ }
+
+ GameObject cbPrefab = goMMC.FindChildByName("CheckboxFreeCam");
+ if (cbPrefab == null)
+ {
+ Multiplayer.LogError("CheckboxFreeCam not found!");
+ return;
+ }
+
+ GameObject selectorPrefab = goMMC.FindChildByName("Crosshair").gameObject;
+ if (selectorPrefab == null)
+ {
+ Multiplayer.LogError("selectorPrefab not found!");
+ return;
+ }
+
+ GameObject sliderPrefab = goMMC.FindChildByName("Field Of View").gameObject;
+ if (sliderPrefab == null)
+ {
+ Multiplayer.LogError("Field Of View not found!");
+ return;
+ }
+
+ GameObject inputPrefab = MainMenuThingsAndStuff.Instance.references.popupTextInput.gameObject.FindChildByName("TextFieldTextIcon");
+ if (inputPrefab == null)
+ {
+ Multiplayer.LogError("TextFieldTextIcon not found!");
+ return;
+ }
+
+
+ lcInstance = goMMC.FindChildByName("PaneRight Launcher").GetComponent();
+ if (lcInstance == null)
+ {
+ Multiplayer.LogError("No Run Button");
+ return;
+ }
+ Sprite playSprite = lcInstance.runButton.FindChildByName("[icon]").GetComponent().sprite;
+
+
+ //update title
+ GameObject titleObj = this.FindChildByName("Title");
+ GameObject.Destroy(titleObj.GetComponentInChildren());
+ titleObj.GetComponentInChildren().key = Locale.SERVER_HOST__TITLE_KEY;
+ titleObj.GetComponentInChildren().UpdateLocalization();
+
+ //update right hand info pane (this will be used later for more settings or information
+ GameObject serverWindowGO = this.FindChildByName("Save Description");
+ GameObject serverDetailsGO = serverWindowGO.FindChildByName("text list [noloc]");
+ HyperlinkHandler hyperLinks = serverDetailsGO.GetOrAddComponent();
+
+ hyperLinks.linkColor = new Color(0.302f, 0.651f, 1f); // #4DA6FF
+ hyperLinks.linkHoverColor = new Color(0.498f, 0.749f, 1f); // #7FBFFF
+
+ serverWindowGO.name = "Host Details";
+ serverDetails = serverDetailsGO.GetComponent();
+ serverDetails.textWrappingMode = TextWrappingModes.Normal;
+ serverDetails.text = Locale.Get(Locale.SERVER_HOST__INSTRUCTIONS_FIRST_KEY, ["", ""]) + "
" +
+ Locale.Get(Locale.SERVER_HOST__MOD_WARNING_KEY, ["", ""]) + "
" +
+ Locale.SERVER_HOST__RECOMMEND + "
" +
+ Locale.SERVER_HOST__SIGNOFF;
+ /*"First time hosts, please see the Hosting section of our Wiki.
" +
+
+ "Using other mods may cause unexpected behaviour including de-syncs. See Mod Compatibility for more info.
" +
+ "It is recommended that other mods are disabled and Derail Valley restarted prior to playing in multiplayer.
" +
+
+ "We hope to have your favourite mods compatible with multiplayer in the future.";*/
+
+
+ //Find scrolling viewport
+ ScrollRect scroller = this.FindChildByName("Scroll View").GetComponent();
+ RectTransform scrollerRT = scroller.transform.GetComponent();
+ scrollerRT.sizeDelta = new Vector2(scrollerRT.sizeDelta.x, 504);
+
+ // Create the content object
+ GameObject controls = new("Controls");
+ controls.SetLayersRecursive(Layers.UI);
+ controls.transform.SetParent(scroller.viewport.transform, false);
+
+ // Assign the content object to the ScrollRect
+ RectTransform contentRect = controls.AddComponent();
+ contentRect.anchorMin = new Vector2(0, 1);
+ contentRect.anchorMax = new Vector2(1, 1);
+ contentRect.pivot = new Vector2(0f, 1);
+ contentRect.anchoredPosition = new Vector2(0, 21);
+ contentRect.sizeDelta = scroller.viewport.sizeDelta;
+ scroller.content = contentRect;
+
+ // Add VerticalLayoutGroup and ContentSizeFitter
+ VerticalLayoutGroup layoutGroup = controls.AddComponent();
+ layoutGroup.childControlWidth = false;
+ layoutGroup.childControlHeight = false;
+ layoutGroup.childScaleWidth = false;
+ layoutGroup.childScaleHeight = false;
+ layoutGroup.childForceExpandWidth = true;
+ layoutGroup.childForceExpandHeight = true;
+
+ layoutGroup.spacing = 0; // Adjust the spacing as needed
+ layoutGroup.padding = new RectOffset(0, 0, 0, 0);
+
+ ContentSizeFitter sizeFitter = controls.AddComponent();
+ sizeFitter.verticalFit = ContentSizeFitter.FitMode.PreferredSize;
+
+ /*
+ * Server name field
+ */
+ GameObject go = GameObject.Instantiate(inputPrefab, NewContentGroup(controls, scroller.viewport.sizeDelta).transform, false);
+ go.name = "Server Name";
+ serverName = go.GetComponent();
+ serverName.text = Multiplayer.Settings.ServerName?.Trim().Substring(0, Mathf.Min(Multiplayer.Settings.ServerName.Trim().Length, MAX_SERVER_NAME_LEN));
+ serverName.placeholder.GetComponent().text = Locale.SERVER_HOST_NAME;
+ serverName.characterLimit = MAX_SERVER_NAME_LEN;
+ go.AddComponent();
+ go.ResetTooltip();
+
+ /*
+ * Server password field
+ */
+ go = GameObject.Instantiate(inputPrefab, NewContentGroup(controls, scroller.viewport.sizeDelta).transform, false);
+ go.name = "Password";
+ password = go.GetComponent();
+ password.text = Multiplayer.Settings.Password;
+ //password.contentType = TMP_InputField.ContentType.Password; //re-introduce later when code for toggling has been implemented
+ password.placeholder.GetComponent().text = Locale.SERVER_HOST_PASSWORD;
+ go.AddComponent();//.enabledKey = Locale.SERVER_HOST_PASSWORD__TOOLTIP_KEY;
+ go.ResetTooltip();
+
+ /*
+ * Server visibility field
+ */
+ selectorPrefab.SetActive(false);
+ go = GameObject.Instantiate(selectorPrefab, NewContentGroup(controls, scroller.viewport.sizeDelta).transform, false);
+ selectorPrefab.SetActive(true);
+ gameVisibility = go.GetOrAddComponent();
+
+ //clean-up
+
+ if (gameVisibility.labelTMPro?.gameObject.TryGetComponent(out var loc) ?? false)
+ GameObject.DestroyImmediate(loc);
+ if (gameVisibility.labelTMPro?.gameObject.TryGetComponent(out var loc2) ?? false)
+ GameObject.DestroyImmediate(loc2);
+
+ DestroyImmediate(go.GetComponent());
+
+ go.name = "Visibility";
+ gameVisibility.initialized = false;
+
+ gameVisibility.LocalizedLabel = true;
+ gameVisibility.SetLabel(Locale.SERVER_HOST_VISIBILITY_KEY);
+ gameVisibility.labelTMPro.GetComponent().key = Locale.SERVER_HOST_VISIBILITY_KEY;
+
+ gameVisibility.LocalizedValues = true;
+ gameVisibility.SetValues(Locale.SERVER_HOST_VISIBILITY_MODES.ToList());
+ gameVisibility.SetSelectedIndex(3);
+
+ go.SetActive(true);
+ go.ResetTooltip();
+
+ gameVisibility.ToggleInteractable(true);
+
+ /*
+ * Server details field
+ */
+ go = GameObject.Instantiate(inputPrefab, NewContentGroup(controls, scroller.viewport.sizeDelta, 106).transform, false);
+ go.name = "Details";
+ go.transform.GetComponent().sizeDelta = new Vector2(go.transform.GetComponent().sizeDelta.x, 106);
+ details = go.GetComponent();
+ details.characterLimit = MAX_DETAILS_LEN;
+ details.lineType = TMP_InputField.LineType.MultiLineNewline;
+ details.FindChildByName("text [noloc]").GetComponent().alignment = TextAlignmentOptions.TopLeft;
+ details.placeholder.GetComponent().text = Locale.SERVER_HOST_DETAILS;
+
+ //Divider
+ go = GameObject.Instantiate(dividerPrefab, NewContentGroup(controls, scroller.viewport.sizeDelta).transform, false);
+ go.name = "Divider";
+
+ /*
+ * Server max players field
+ */
+ sliderPrefab.SetActive(false);
+ go = GameObject.Instantiate(sliderPrefab, NewContentGroup(controls, scroller.viewport.sizeDelta).transform, false);
+ sliderPrefab.SetActive(true);
+ maxPlayers = go.GetComponent();
+
+ go.name = "Max Players";
+ var labelGo = go.FindChildByName("[text label]");
+
+ if (labelGo?.gameObject.TryGetComponent(out loc) ?? false)
+ GameObject.DestroyImmediate(loc);
+
+ DestroyImmediate(go.GetComponent());
+
+ labelGo.GetComponent().key = Locale.SERVER_HOST_MAX_PLAYERS_KEY;
+ go.ResetTooltip();
+ //labelGo.GetComponent().UpdateLocalization();
+
+ maxPlayers.stepIncrement = 1;
+ maxPlayers.minValue = MIN_PLAYERS;
+ maxPlayers.maxValue = MAX_PLAYERS;
+ maxPlayers.value = Mathf.Clamp(Multiplayer.Settings.MaxPlayers, MIN_PLAYERS, MAX_PLAYERS);
+ go.SetActive(true);
+ maxPlayers.interactable = true;
+
+ /*
+ * Server port field
+ */
+ go = GameObject.Instantiate(inputPrefab, NewContentGroup(controls, scroller.viewport.sizeDelta).transform, false);
+ go.name = "Port";
+ port = go.GetComponent();
+ port.characterValidation = TMP_InputField.CharacterValidation.Integer;
+ port.characterLimit = MAX_PORT_LEN;
+ port.placeholder.GetComponent().text = DEFAULT_PORT.ToString();
+ port.text = (Multiplayer.Settings.Port >= MIN_PORT && Multiplayer.Settings.Port <= MAX_PORT) ? Multiplayer.Settings.Port.ToString() : DEFAULT_PORT.ToString();
+
+ /*
+ * Start Game button
+ */
+ go = this.gameObject.UpdateButton("ButtonTextIcon Save", "ButtonTextIcon Start", Locale.SERVER_HOST_START_KEY, null, playSprite);
+ go.FindChildByName("[text]").GetComponent().UpdateLocalization();
+
+ startButton = go.GetComponent();
+ startButton.onClick.RemoveAllListeners();
+ startButton.onClick.AddListener(OnStartClick);
+ }
+
+ private GameObject NewContentGroup(GameObject parent, Vector2 sizeDelta, int cellMaxHeight = 53)
+ {
+ // Create a content group
+ GameObject contentGroup = new("ContentGroup");
+ contentGroup.SetLayersRecursive(Layers.UI);
+ RectTransform groupRect = contentGroup.AddComponent();
+ contentGroup.transform.SetParent(parent.transform, false);
+ groupRect.sizeDelta = sizeDelta;
+
+ ContentSizeFitter sizeFitter = contentGroup.AddComponent();
+ sizeFitter.verticalFit = ContentSizeFitter.FitMode.PreferredSize;
+
+ // Add VerticalLayoutGroup and ContentSizeFitter
+ GridLayoutGroup glayoutGroup = contentGroup.AddComponent();
+ glayoutGroup.startCorner = GridLayoutGroup.Corner.LowerLeft;
+ glayoutGroup.startAxis = GridLayoutGroup.Axis.Vertical;
+ glayoutGroup.cellSize = new Vector2(617.5f, cellMaxHeight);
+ glayoutGroup.spacing = new Vector2(0, 0);
+ glayoutGroup.constraint = GridLayoutGroup.Constraint.FixedColumnCount;
+ glayoutGroup.constraintCount = 1;
+ glayoutGroup.padding = new RectOffset(10, 0, 0, 10);
+
+ return contentGroup;
+ }
+
+ private void SetupListeners(bool on)
+ {
+ if (on)
+ {
+ serverName.onValueChanged.RemoveAllListeners();
+ serverName.onValueChanged.AddListener(new UnityAction(ValidateInputs));
+
+ port.onValueChanged.RemoveAllListeners();
+ port.onValueChanged.AddListener(new UnityAction(ValidateInputs));
+ }
+ else
+ {
+ this.serverName.onValueChanged.RemoveAllListeners();
+ }
+
+ }
+
+ #endregion
+
+ #region UI callbacks
+ private void ValidateInputs(string _)
+ {
+ bool valid = true;
+
+ if (incompatibleMods)
+ valid = false;
+
+ if (!DVSteamworks.Success)
+ valid = false;
+
+ if (serverName.text.Trim() == "" || serverName.text.Length > MAX_SERVER_NAME_LEN)
+ valid = false;
+
+ if (port.text != "")
+ {
+ if (!int.TryParse(port.text, out int portNum) || portNum < MIN_PORT || portNum > MAX_PORT)
+ valid = false;
+ }
+
+ if (port.text == "" && (Multiplayer.Settings.Port < MIN_PORT || Multiplayer.Settings.Port > MAX_PORT))
+ valid = false;
+
+ startButton.ToggleInteractable(valid);
+ }
+
+ private void OnStartClick()
+ {
+
+ using (LobbyServerData serverData = new())
+ {
+ serverData.port = (port.text == "") ? Multiplayer.Settings.Port : int.Parse(port.text); ;
+ serverData.Name = serverName.text.Trim();
+ serverData.HasPassword = password.text != "";
+ serverData.Visibility = (ServerVisibility)gameVisibility.SelectedIndex;
+
+ serverData.GameMode = 0; //replaced with details from save / new game
+ serverData.Difficulty = 0; //replaced with details from save / new game
+ serverData.TimePassed = "N/A"; //replaced with details from save, or persisted if new game (will be updated in lobby server update cycle)
+
+ serverData.CurrentPlayers = 0;
+ serverData.MaxPlayers = (int)maxPlayers.value;
+
+ // final check before we start the server
+ var requiredMods = ModCompatibilityManager.Instance.GetLocalMods();
+ if (requiredMods == null)
+ {
+ incompatibleMods = true;
+ ValidateInputs(null);
+ return;
+ }
+
+ serverData.RequiredMods = requiredMods;
+ serverData.GameVersion = MainMenuControllerPatch.MenuProvider.BuildVersionString;
+ serverData.MultiplayerVersion = Multiplayer.Ver;
+
+ serverData.ServerDetails = details.text.Trim();
+
+ if (saveGame != null)
+ {
+ ISaveGameplayInfo saveGameplayInfo = this.userProvider.GetSaveGameplayInfo(this.saveGame);
+ if (!saveGameplayInfo.IsCorrupt)
+ {
+ serverData.TimePassed = (saveGameplayInfo.InGameDate != DateTime.MinValue) ? saveGameplayInfo.InGameTimePassed.ToString("d\\d\\ hh\\h\\ mm\\m\\ ss\\s") : "N/A";
+ serverData.Difficulty = LobbyServerData.GetDifficultyFromString(this.userProvider.GetSessionDifficulty(saveGame.ParentSession).Name);
+ serverData.GameMode = LobbyServerData.GetGameModeFromString(saveGame.GameMode);
+ }
+ }
+ else if (startGameData != null)
+ {
+ serverData.Difficulty = LobbyServerData.GetDifficultyFromString(this.startGameData.difficulty.Name);
+ serverData.GameMode = LobbyServerData.GetGameModeFromString(startGameData.session.GameMode);
+ }
+
+ Multiplayer.Settings.ServerName = serverData.Name;
+ Multiplayer.Settings.Password = password.text;
+ Multiplayer.Settings.Visibility = serverData.Visibility;
+ Multiplayer.Settings.Port = serverData.port;
+ Multiplayer.Settings.MaxPlayers = serverData.MaxPlayers;
+ Multiplayer.Settings.Details = serverData.ServerDetails;
+
+ // Pass the server data to the NetworkLifecycle manager
+ NetworkLifecycle.Instance.serverData = serverData;
+ }
+
+ // Mark it as a real multiplayer game
+ NetworkLifecycle.Instance.IsSinglePlayer = false;
+
+ var ContinueGameRequested = lcInstance.GetType().GetMethod("OnRunClicked", BindingFlags.NonPublic | BindingFlags.Instance);
+
+ //Multiplayer.Log($"OnRunClicked exists: {ContinueGameRequested != null}");
+ ContinueGameRequested?.Invoke(lcInstance, null);
+ }
+ #endregion
+
+
+}
diff --git a/Multiplayer/Components/MainMenu/MainMenuThingsAndStuff.cs b/Multiplayer/Components/MainMenu/MainMenuThingsAndStuff.cs
index 02a6d6b2..2dea38f7 100644
--- a/Multiplayer/Components/MainMenu/MainMenuThingsAndStuff.cs
+++ b/Multiplayer/Components/MainMenu/MainMenuThingsAndStuff.cs
@@ -1,93 +1,150 @@
using System;
+using DV.UI;
using DV.UIFramework;
using DV.Utils;
using JetBrains.Annotations;
using UnityEngine;
-namespace Multiplayer.Components.MainMenu;
-
-public class MainMenuThingsAndStuff : SingletonBehaviour
+namespace Multiplayer.Components.MainMenu
{
- public PopupManager popupManager;
- public Popup renamePopupPrefab;
- public Popup okPopupPrefab;
- public UIMenuController uiMenuController;
-
- protected override void Awake()
+ public class MainMenuThingsAndStuff : SingletonBehaviour
{
- bool shouldDestroy = false;
+ public PopupManager popupManager;
+ //public Popup renamePopupPrefab;
+ //public Popup okPopupPrefab;
+ //public Popup yesNoPopupPrefab;
+ public UIMenuController uiMenuController;
+ public PopupNotificationReferences references;
- if (popupManager == null)
+ protected override void Awake()
{
- Multiplayer.LogError("Failed to find PopupManager! Destroying self.");
- shouldDestroy = true;
+ bool shouldDestroy = false;
+
+ popupManager = GameObject.FindObjectOfType();
+ references = GameObject.FindObjectOfType();
+
+ // Check if PopupManager is assigned
+ if (popupManager == null)
+ {
+ Multiplayer.LogError("Failed to find PopupManager! Destroying self.");
+ shouldDestroy = true;
+ }
+
+ //// Check if renamePopupPrefab is assigned
+ //if (renamePopupPrefab == null)
+ //{
+ // Multiplayer.LogError($"{nameof(renamePopupPrefab)} is null! Destroying self.");
+ // shouldDestroy = true;
+ //}
+
+ //// Check if okPopupPrefab is assigned
+ //if (okPopupPrefab == null)
+ //{
+ // Multiplayer.LogError($"{nameof(okPopupPrefab)} is null! Destroying self.");
+ // shouldDestroy = true;
+ //}
+
+ // Check if uiMenuController is assigned
+ if (uiMenuController == null)
+ {
+ Multiplayer.LogError($"{nameof(uiMenuController)} is null! Destroying self.");
+ shouldDestroy = true;
+ }
+
+ // If all required components are assigned, call base.Awake(), otherwise destroy self
+ if (!shouldDestroy)
+ {
+ base.Awake();
+ return;
+ }
+
+ Destroy(this);
}
- if (renamePopupPrefab == null)
+ // Switch to the default menu
+ public void SwitchToDefaultMenu()
{
- Multiplayer.LogError($"{nameof(renamePopupPrefab)} is null! Destroying self.");
- shouldDestroy = true;
+ uiMenuController.SwitchMenu(uiMenuController.defaultMenuIndex);
}
- if (okPopupPrefab == null)
+ // Switch to a specific menu by index
+ public void SwitchToMenu(byte index)
{
- Multiplayer.LogError($"{nameof(okPopupPrefab)} is null! Destroying self.");
- shouldDestroy = true;
+ if (uiMenuController.ActiveIndex == index)
+ return;
+
+ uiMenuController.SwitchMenu(index);
}
- if (uiMenuController == null)
+ // Show the rename popup if possible
+ [CanBeNull]
+ public Popup ShowRenamePopup()
{
- Multiplayer.LogError($"{nameof(uiMenuController)} is null! Destroying self.");
- shouldDestroy = true;
+ Multiplayer.Log("public Popup ShowRenamePopup() ...");
+ return ShowPopup(references.popupTextInput);
}
- if (!shouldDestroy)
+ // Show the OK popup if possible
+ [CanBeNull]
+ public Popup ShowOkPopup()
{
- base.Awake();
- return;
+ return ShowPopup(references.popupOk);
}
- Destroy(this);
- }
+ // Show the Yes No popup if possible
+ [CanBeNull]
+ public Popup ShowYesNoPopup()
+ {
+ return ShowPopup(references.popupYesNo);
+ }
- public void SwitchToDefaultMenu()
- {
- uiMenuController.SwitchMenu(uiMenuController.defaultMenuIndex);
- }
+ // Show the Wait Spinner popup if possible
+ [CanBeNull]
+ public Popup ShowSpinnerPopup()
+ {
+ return ShowPopup(references.popupWaitSpinner);
+ }
+
+ // Show the Slider popup if possible
+ [CanBeNull]
+ public Popup ShowSliderPopup()
+ {
+ return ShowPopup(references.popupSlider);
+ }
- public void SwitchToMenu(byte index)
- {
- uiMenuController.SwitchMenu(index);
- }
+ // Generic method to show a popup if the PopupManager can show it
+ [CanBeNull]
+ private Popup ShowPopup(Popup popup)
+ {
+ if (popupManager.CanShowPopup())
+ return popupManager.ShowPopup(popup);
- [CanBeNull]
- public Popup ShowRenamePopup()
- {
- return ShowPopup(renamePopupPrefab);
- }
+ Multiplayer.LogError($"{nameof(PopupManager)} cannot show popup!");
+ return null;
+ }
- [CanBeNull]
- public Popup ShowOkPopup()
- {
- return ShowPopup(okPopupPrefab);
- }
+ public void ShowOkPopup(string text, Action onClick)
+ {
+ var popup = ShowOkPopup();
+ if (popup == null) return;
- [CanBeNull]
- private Popup ShowPopup(Popup popup)
- {
- if (popupManager.CanShowPopup())
- return popupManager.ShowPopup(popup);
- Multiplayer.LogError($"{nameof(PopupManager)} cannot show popup!");
- return null;
- }
+ popup.labelTMPro.text = text;
+ popup.Closed += _ => onClick();
+ }
- /// A function to apply to the MainMenuPopupManager while the object is disabled
- public static void Create(Action func)
- {
- GameObject go = new($"[{nameof(MainMenuThingsAndStuff)}]");
- go.SetActive(false);
- MainMenuThingsAndStuff manager = go.AddComponent();
- func.Invoke(manager);
- go.SetActive(true);
+ /// A function to apply to the MainMenuPopupManager while the object is disabled
+ public static void Create(Action func)
+ {
+ // Create a new GameObject for MainMenuThingsAndStuff and disable it
+ GameObject go = new($"[{nameof(MainMenuThingsAndStuff)}]");
+ go.SetActive(false);
+
+ // Add MainMenuThingsAndStuff component and apply the provided function
+ MainMenuThingsAndStuff manager = go.AddComponent();
+ func.Invoke(manager);
+
+ // Re-enable the GameObject
+ go.SetActive(true);
+ }
}
}
diff --git a/Multiplayer/Components/MainMenu/MultiplayerPane.cs b/Multiplayer/Components/MainMenu/MultiplayerPane.cs
deleted file mode 100644
index be420685..00000000
--- a/Multiplayer/Components/MainMenu/MultiplayerPane.cs
+++ /dev/null
@@ -1,120 +0,0 @@
-using System;
-using System.Text.RegularExpressions;
-using DV.UIFramework;
-using DV.Utils;
-using Multiplayer.Components.Networking;
-using UnityEngine;
-
-namespace Multiplayer.Components.MainMenu;
-
-public class MultiplayerPane : MonoBehaviour
-{
- // @formatter:off
- // Patterns from https://ihateregex.io/
- private static readonly Regex IPv4 = new(@"(\b25[0-5]|\b2[0-4][0-9]|\b[01]?[0-9][0-9]?)(\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}");
- private static readonly Regex IPv6 = new(@"(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))");
- private static readonly Regex PORT = new(@"^((6553[0-5])|(655[0-2][0-9])|(65[0-4][0-9]{2})|(6[0-4][0-9]{3})|([1-5][0-9]{4})|([0-5]{0,5})|([0-9]{1,4}))$");
- // @formatter:on
-
- private bool why;
-
- private string address;
- private ushort port;
-
- private void OnEnable()
- {
- if (!why)
- {
- why = true;
- return;
- }
-
- ShowIpPopup();
- }
-
- private void ShowIpPopup()
- {
- Popup popup = MainMenuThingsAndStuff.Instance.ShowRenamePopup();
- if (popup == null)
- return;
-
- popup.labelTMPro.text = Locale.SERVER_BROWSER__IP;
-
- popup.Closed += result =>
- {
- if (result.closedBy == PopupClosedByAction.Abortion)
- {
- MainMenuThingsAndStuff.Instance.SwitchToDefaultMenu();
- return;
- }
-
- if (!IPv4.IsMatch(result.data) && !IPv6.IsMatch(result.data))
- {
- ShowOkPopup(Locale.SERVER_BROWSER__IP_INVALID, ShowIpPopup);
- return;
- }
-
- address = result.data;
-
- ShowPortPopup();
- };
- }
-
- private void ShowPortPopup()
- {
- Popup popup = MainMenuThingsAndStuff.Instance.ShowRenamePopup();
- if (popup == null)
- return;
-
- popup.labelTMPro.text = Locale.SERVER_BROWSER__PORT;
-
- popup.Closed += result =>
- {
- if (result.closedBy == PopupClosedByAction.Abortion)
- {
- MainMenuThingsAndStuff.Instance.SwitchToDefaultMenu();
- return;
- }
-
- if (!PORT.IsMatch(result.data))
- {
- ShowOkPopup(Locale.SERVER_BROWSER__PORT_INVALID, ShowPortPopup);
- return;
- }
-
- port = ushort.Parse(result.data);
-
- ShowPasswordPopup();
- };
- }
-
- private void ShowPasswordPopup()
- {
- Popup popup = MainMenuThingsAndStuff.Instance.ShowRenamePopup();
- if (popup == null)
- return;
-
- popup.labelTMPro.text = Locale.SERVER_BROWSER__PASSWORD;
-
- popup.Closed += result =>
- {
- if (result.closedBy == PopupClosedByAction.Abortion)
- {
- MainMenuThingsAndStuff.Instance.SwitchToDefaultMenu();
- return;
- }
-
- SingletonBehaviour.Instance.StartClient(address, port, result.data);
- };
- }
-
- private static void ShowOkPopup(string text, Action onClick)
- {
- Popup popup = MainMenuThingsAndStuff.Instance.ShowOkPopup();
- if (popup == null)
- return;
-
- popup.labelTMPro.text = text;
- popup.Closed += _ => { onClick(); };
- }
-}
diff --git a/Multiplayer/Components/MainMenu/ServerBrowser/IServerBrowserGameDetails.cs b/Multiplayer/Components/MainMenu/ServerBrowser/IServerBrowserGameDetails.cs
new file mode 100644
index 00000000..ddbba69d
--- /dev/null
+++ b/Multiplayer/Components/MainMenu/ServerBrowser/IServerBrowserGameDetails.cs
@@ -0,0 +1,35 @@
+using Multiplayer.Networking.Data;
+using System;
+
+namespace Multiplayer.Components.MainMenu;
+
+public enum ServerVisibility : int
+{
+ Private = 0,
+ Friends = 1,
+ Public = 2
+}
+
+public interface IServerBrowserGameDetails : IDisposable
+{
+ string id { get; set; }
+ string ipv6 { get; set; }
+ string ipv4 { get; set; }
+ string LocalIPv4 { get; set; }
+ string LocalIPv6 { get; set; }
+ int port { get; set; }
+ string Name { get; set; }
+ bool HasPassword { get; set; }
+ int GameMode { get; set; }
+ int Difficulty { get; set; }
+ string TimePassed { get; set; }
+ int CurrentPlayers { get; set; }
+ int MaxPlayers { get; set; }
+ ModInfo[] RequiredMods { get; set; }
+ string GameVersion { get; set; }
+ string MultiplayerVersion { get; set; }
+ string ServerDetails { get; set; }
+ int Ping {get; set; }
+ ServerVisibility Visibility { get; set; }
+ int LastSeen { get; set; }
+}
diff --git a/Multiplayer/Components/MainMenu/ServerBrowser/PopupTextInputFieldControllerNoValidation.cs b/Multiplayer/Components/MainMenu/ServerBrowser/PopupTextInputFieldControllerNoValidation.cs
new file mode 100644
index 00000000..170caabd
--- /dev/null
+++ b/Multiplayer/Components/MainMenu/ServerBrowser/PopupTextInputFieldControllerNoValidation.cs
@@ -0,0 +1,93 @@
+using System;
+using System.Reflection;
+using DV.UIFramework;
+using TMPro;
+using UnityEngine;
+using UnityEngine.Events;
+
+namespace Multiplayer.Components.MainMenu
+{
+ public class PopupTextInputFieldControllerNoValidation : MonoBehaviour, IPopupSubmitHandler
+ {
+ public Popup popup;
+ public TMP_InputField field;
+ public ButtonDV confirmButton;
+
+ private void Awake()
+ {
+ // Find the components
+ popup = this.GetComponentInParent();
+ field = popup.GetComponentInChildren();
+
+ foreach (ButtonDV btn in popup.GetComponentsInChildren())
+ {
+ if (btn.name == "ButtonYes")
+ {
+ confirmButton = btn;
+ }
+ }
+
+ // Set this instance as the new handler for the dialog
+ typeof(Popup).GetField("handler", BindingFlags.NonPublic | BindingFlags.Instance).SetValue(popup, this);
+ }
+
+ private void Start()
+ {
+ // Add listener for input field value changes
+ field.onValueChanged.AddListener(new UnityAction(OnInputValueChanged));
+ OnInputValueChanged(field.text);
+ field.Select();
+ field.ActivateInputField();
+ }
+
+ private void OnInputValueChanged(string value)
+ {
+ // Toggle confirm button interactability based on input validity
+ confirmButton.ToggleInteractable(IsInputValid(value));
+ }
+
+ public void HandleAction(PopupClosedByAction action)
+ {
+ switch (action)
+ {
+ case PopupClosedByAction.Positive:
+ if (IsInputValid(field.text))
+ {
+ RequestPositive();
+ return;
+ }
+ break;
+ case PopupClosedByAction.Negative:
+ RequestNegative();
+ return;
+ case PopupClosedByAction.Abortion:
+ RequestAbortion();
+ return;
+ default:
+ Multiplayer.LogError(string.Format("Unhandled action {0}", action));
+ break;
+ }
+ }
+
+ private bool IsInputValid(string value)
+ {
+ // Always return true to disable validation
+ return true;
+ }
+
+ private void RequestPositive()
+ {
+ this.popup.RequestClose(PopupClosedByAction.Positive, this.field.text);
+ }
+
+ private void RequestNegative()
+ {
+ this.popup.RequestClose(PopupClosedByAction.Negative, null);
+ }
+
+ private void RequestAbortion()
+ {
+ this.popup.RequestClose(PopupClosedByAction.Abortion, null);
+ }
+ }
+}
diff --git a/Multiplayer/Components/MainMenu/ServerBrowser/ServerBrowserElement.cs b/Multiplayer/Components/MainMenu/ServerBrowser/ServerBrowserElement.cs
new file mode 100644
index 00000000..8cbef966
--- /dev/null
+++ b/Multiplayer/Components/MainMenu/ServerBrowser/ServerBrowserElement.cs
@@ -0,0 +1,137 @@
+
+using Multiplayer.Components.UI.Controls;
+using Multiplayer.Utils;
+using TMPro;
+using UnityEngine;
+using UnityEngine.UI;
+
+namespace Multiplayer.Components.MainMenu.ServerBrowser
+{
+ public class ServerBrowserElement : MPViewElement
+ {
+ public override bool IsPlaceholder => false;
+
+ private TextMeshProUGUI serverName;
+ private TextMeshProUGUI playerCount;
+ private TextMeshProUGUI ping;
+ private GameObject goIconPassword;
+ private Image iconPassword;
+ private GameObject goIconLAN;
+ private Image iconLAN;
+ private IServerBrowserGameDetails data;
+
+ private const int PING_WIDTH = 124; // Adjusted width for the ping text
+ private const int PING_PADDING_X = 10;
+
+ private const string PING_COLOR_UNKNOWN = "#808080";
+ private const string PING_COLOR_EXCELLENT = "#00ff00";
+ private const string PING_COLOR_GOOD = "#ffa500";
+ private const string PING_COLOR_HIGH = "#ff4500";
+ private const string PING_COLOR_POOR = "#ff0000";
+
+ private const int PING_THRESHOLD_NONE = -1;
+ private const int PING_THRESHOLD_EXCELLENT = 60;
+ private const int PING_THRESHOLD_GOOD = 100;
+ private const int PING_THRESHOLD_HIGH = 150;
+
+ public override void Awake()
+ {
+ // Find and assign TextMeshProUGUI components for displaying server details
+ serverName = this.FindChildByName("name [noloc]").GetComponent();
+ playerCount = this.FindChildByName("date [noloc]").GetComponent();
+ ping = this.FindChildByName("time [noloc]").GetComponent();
+ goIconPassword = this.FindChildByName("autosave icon");
+ iconPassword = goIconPassword.GetComponent();
+
+ RectTransform nameRT = serverName.rectTransform;
+
+ // Align player count
+ RectTransform playerCountRT = playerCount.rectTransform;
+ playerCountRT.anchorMin = new Vector2(0, 0.5f);
+ playerCountRT.anchorMax = new Vector2(0, 0.5f);
+ playerCountRT.pivot = new Vector2(0, 0.5f);
+
+ float nameWidth = nameRT.sizeDelta.x;
+ playerCountRT.anchoredPosition = new Vector2(nameRT.position.x + nameWidth, nameRT.anchoredPosition.y);
+
+ // Align ping
+ RectTransform pingRT = ping.rectTransform;
+ pingRT.anchorMin = new Vector2(0, 0.5f);
+ pingRT.anchorMax = new Vector2(0, 0.5f);
+ pingRT.pivot = new Vector2(0, 0.5f);
+
+ RectTransform parentRT = transform as RectTransform;
+ float pingX = parentRT.rect.width - PING_WIDTH - PING_PADDING_X;
+ pingRT.anchoredPosition = new Vector2(pingX, nameRT.anchoredPosition.y);
+ pingRT.sizeDelta = new Vector2(PING_WIDTH, pingRT.sizeDelta.y);
+ ping.alignment = TextAlignmentOptions.Right;
+
+
+ // Set password icon
+ iconPassword.sprite = Multiplayer.AssetIndex.lockIcon;
+
+ // Set LAN icon
+ if(this.HasChildWithName("LAN Icon"))
+ {
+ goIconLAN = this.FindChildByName("LAN Icon");
+ }
+ else
+ {
+ goIconLAN = Instantiate(goIconPassword, goIconPassword.transform.parent);
+ goIconLAN.name = "LAN Icon";
+ Vector3 LANpos = goIconLAN.transform.localPosition;
+ Vector3 LANSize = goIconLAN.GetComponent().sizeDelta;
+ LANpos.x += (pingRT.position.x - LANpos.x - LANSize.x) / 2;
+ goIconLAN.transform.localPosition = LANpos;
+ iconLAN = goIconLAN.GetComponent();
+ iconLAN.sprite = Multiplayer.AssetIndex.lanIcon;
+ }
+
+ }
+
+ public override void SetData(IServerBrowserGameDetails data)
+ {
+ // Clear existing data
+ if (this.data != null)
+ {
+ this.data = null;
+ }
+ // Set new data
+ if (data != null)
+ {
+ this.data = data;
+ }
+ // Update the view with the new data
+ UpdateView();
+ }
+
+ public void UpdateView()
+ {
+ //Multiplayer.LogDebug(() => $"UpdateView() serverName: {data.Name}, ping: {data.Ping}");
+
+ // Update the text fields with the data from the server
+ serverName.text = data.Name;
+ playerCount.text = $"{data.CurrentPlayers} / {data.MaxPlayers}";
+
+ ping.text = $"{(data.Ping < 0 ? "?" : data.Ping)} ms";
+
+ // Hide the icon if the server does not have a password
+ goIconPassword.SetActive(data.HasPassword);
+
+ bool isLan = !string.IsNullOrEmpty(data.LocalIPv4) || !string.IsNullOrEmpty(data.LocalIPv6);
+ goIconLAN.SetActive(isLan);
+ }
+
+ private string GetColourForPing(int ping)
+ {
+ return ping switch
+ {
+ PING_THRESHOLD_NONE => PING_COLOR_UNKNOWN,
+ < PING_THRESHOLD_EXCELLENT => PING_COLOR_EXCELLENT,
+ < PING_THRESHOLD_GOOD => PING_COLOR_GOOD,
+ < PING_THRESHOLD_HIGH => PING_COLOR_HIGH,
+ _ => PING_COLOR_POOR,
+ };
+ }
+ }
+}
diff --git a/Multiplayer/Components/MainMenu/ServerBrowser/ServerBrowserGridView.cs b/Multiplayer/Components/MainMenu/ServerBrowser/ServerBrowserGridView.cs
new file mode 100644
index 00000000..ddbef4c9
--- /dev/null
+++ b/Multiplayer/Components/MainMenu/ServerBrowser/ServerBrowserGridView.cs
@@ -0,0 +1,36 @@
+using DV.UI;
+using Multiplayer.Components.UI.Controls;
+using UnityEngine;
+using UnityEngine.UI;
+
+namespace Multiplayer.Components.MainMenu.ServerBrowser;
+
+[RequireComponent(typeof(ContentSizeFitter))]
+[RequireComponent(typeof(VerticalLayoutGroup))]
+public class ServerBrowserGridView : MPGridView
+{
+
+ protected override void Awake()
+ {
+ showPlaceholderWhenEmpty = true;
+
+ //copy the copy
+ viewElementPrefab.SetActive(false);
+ placeholderElementPrefab = Instantiate(viewElementPrefab);
+
+ //swap controllers
+ Destroy(viewElementPrefab.GetComponent());
+ GameObject.Destroy(placeholderElementPrefab.GetComponent());
+
+ viewElementPrefab.AddComponent();
+ placeholderElementPrefab.AddComponent();
+
+ viewElementPrefab.name = "prefabSBElement";
+ placeholderElementPrefab.name = "prefabSBPlaceholderElement";
+
+ viewElementPrefab.SetActive(true);
+ placeholderElementPrefab.SetActive(true);
+
+ base.Awake();
+ }
+}
diff --git a/Multiplayer/Components/MainMenu/ServerBrowser/ServerBrowserPlaceholderElement.cs b/Multiplayer/Components/MainMenu/ServerBrowser/ServerBrowserPlaceholderElement.cs
new file mode 100644
index 00000000..1b58419c
--- /dev/null
+++ b/Multiplayer/Components/MainMenu/ServerBrowser/ServerBrowserPlaceholderElement.cs
@@ -0,0 +1,48 @@
+using DV.UI;
+using DV.UIFramework;
+using DV.Localization;
+using Multiplayer.Utils;
+using UnityEngine;
+using Multiplayer.Components.UI.Controls;
+
+namespace Multiplayer.Components.MainMenu.ServerBrowser
+{
+ public class ServerBrowserPlaceholderElement : MPViewElement
+ {
+ public override bool IsPlaceholder => true;
+
+ public override void Awake()
+ {
+ // Find and assign TextMeshProUGUI components for displaying server details
+ GameObject networkNameGO = this.FindChildByName("name [noloc]");
+
+ this.FindChildByName("date [noloc]").SetActive(false);
+ this.FindChildByName("time [noloc]").SetActive(false);
+ this.FindChildByName("autosave icon").SetActive(false);
+
+ //Remove doubled up components
+ GameObject.Destroy(this.transform.GetComponent());
+ GameObject.Destroy(this.transform.GetComponent());
+ GameObject.Destroy(this.transform.GetComponent());
+ GameObject.Destroy(this.transform.GetComponent());
+
+ RectTransform networkNameRT = networkNameGO.transform.GetComponent();
+ networkNameRT.sizeDelta = new Vector2(600, networkNameRT.sizeDelta.y);
+
+ this.SetInteractable(false);
+
+ Localize loc = networkNameGO.GetOrAddComponent();
+ loc.key = Locale.SERVER_BROWSER__NO_SERVERS_KEY ;
+ loc.UpdateLocalization();
+
+ this.GetOrAddComponent().enabled = true;
+ this.gameObject.ResetTooltip();
+
+ }
+
+ public override void SetData(IServerBrowserGameDetails data)
+ {
+ //do nothing
+ }
+ }
+}
diff --git a/Multiplayer/Components/MainMenu/ServerBrowserPane.cs b/Multiplayer/Components/MainMenu/ServerBrowserPane.cs
new file mode 100644
index 00000000..81a07683
--- /dev/null
+++ b/Multiplayer/Components/MainMenu/ServerBrowserPane.cs
@@ -0,0 +1,1274 @@
+using DV.Localization;
+using DV.Platform.Steam;
+using DV.UI;
+using DV.UI.Manual;
+using DV.UIFramework;
+using DV.Utils;
+using LiteNetLib;
+using MPAPI.Types;
+using Multiplayer.API;
+using Multiplayer.Components.MainMenu.ServerBrowser;
+using Multiplayer.Components.Networking;
+using Multiplayer.Components.UI.Controls;
+using Multiplayer.Components.Util;
+using Multiplayer.Networking.Data;
+using Multiplayer.Patches.MainMenu;
+using Multiplayer.Utils;
+using Steamworks;
+using Steamworks.Data;
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.Linq;
+using System.Net;
+using System.Text;
+using System.Threading.Tasks;
+using TMPro;
+using UnityEngine;
+using UnityEngine.UI;
+using Color = UnityEngine.Color;
+
+namespace Multiplayer.Components.MainMenu;
+
+public class ServerBrowserPane : MonoBehaviour
+{
+ private const string FORMAT_ALPHA = "";
+
+ private enum ConnectionState
+ {
+ NotConnected,
+ JoiningLobby,
+ AwaitingPassword,
+ AttemptingSteamRelay,
+ AttemptingIPv6,
+ AttemptingIPv6Punch,
+ AttemptingIPv4,
+ AttemptingIPv4Punch,
+ Connected,
+ Failed,
+ Aborted
+ }
+
+ private const int MAX_PORT_LEN = 5;
+ private const int MIN_PORT = 1024;
+ private const int MAX_PORT = 49151;
+
+ // Gridview variables
+ private ServerBrowserGridView serverGridView;
+ private IServerBrowserGameDetails selectedServer;
+
+ // Ping tracking
+ private float pingTimer = 0f;
+ private const float PING_INTERVAL = 2f; // base interval to refresh all pings
+
+ // Button variables
+ private ButtonDV buttonJoin;
+ private ButtonDV buttonRefresh;
+ private ButtonDV buttonDirectIP;
+
+ // Misc GUI Elements
+ private TextMeshProUGUI serverName;
+ private TextMeshProUGUI detailsPane;
+ private GameObject navigationButtonPrefab;
+ private Transform detailsContent;
+ private CollapsibleElement elementRequiredMods;
+ private CollapsibleElement elementExtraMods;
+
+ // Remote server tracking
+ private readonly List remoteServers = [];
+ private bool serverRefreshing = false;
+ private float timePassed = 0f; //time since last refresh
+ private const int AUTO_REFRESH_TIME = 30; //how often to refresh in auto
+ private const int REFRESH_MIN_TIME = 10; //Stop refresh spam
+ private bool remoteRefreshComplete;
+
+ // Connection parameters
+ private string address;
+ private int portNumber;
+ private Lobby? selectedLobby;
+ private static Lobby? joinedLobby;
+ public static Lobby? lobbyToJoin;
+ string password = null;
+ bool direct = false;
+
+ private ConnectionState connectionState = ConnectionState.NotConnected;
+ private Popup connectingPopup;
+ private int attempt;
+
+ private Lobby[] lobbies;
+
+ private bool incompatibleMods = true;
+
+ #region setup
+
+ public void Awake()
+ {
+ Multiplayer.Log("MultiplayerPane Awake()");
+ joinedLobby?.Leave();
+ joinedLobby = null;
+
+ CleanUI();
+ BuildUI();
+
+ SetupServerBrowser();
+ }
+
+ public void OnEnable()
+ {
+ //ensure no incompatible mods are loaded
+ incompatibleMods = ModCompatibilityManager.Instance.CheckModCompatibility();
+
+ this.SetupListeners(true);
+
+ buttonDirectIP.ToggleInteractable(true);
+ buttonRefresh.ToggleInteractable(true);
+
+ RefreshAction();
+ }
+
+ // Disable listeners
+ public void OnDisable()
+ {
+ this.SetupListeners(false);
+ }
+
+ public void Update()
+ {
+ SteamClient.RunCallbacks();
+
+ //Handle server refresh interval
+ timePassed += Time.deltaTime;
+
+ if (!serverRefreshing)
+ {
+ if (timePassed >= AUTO_REFRESH_TIME)
+ {
+ RefreshAction();
+ }
+ else if (timePassed >= REFRESH_MIN_TIME)
+ {
+ buttonRefresh.ToggleInteractable(true);
+ }
+ }
+ else if (remoteRefreshComplete)
+ {
+ RefreshGridView();
+ OnSelectedIndexChanged(serverGridView); //Revalidate any selected servers
+ remoteRefreshComplete = false;
+ serverRefreshing = false;
+ timePassed = 0;
+ }
+
+ //Handle pinging servers
+ pingTimer += Time.deltaTime;
+
+ if (pingTimer >= PING_INTERVAL)
+ {
+ UpdatePings();
+ pingTimer = 0f;
+ }
+
+ if (lobbyToJoin != null && connectionState == ConnectionState.NotConnected)
+ {
+ //For invites/requests
+ Multiplayer.Log($"Player invite initiated/request");
+
+ if (lobbyToJoin.Value.Id.IsValid)
+ {
+ direct = false;
+ var _ = JoinLobby((Lobby)lobbyToJoin);
+ }
+ else
+ {
+ Multiplayer.LogWarning("Received invalid lobby invite");
+ lobbyToJoin = null;
+ }
+ }
+ }
+
+ public void Start()
+ {
+ if (DVSteamworks.Success)
+ return;
+
+ Multiplayer.Log($"Steam not detected, prompt for restart.");
+ MainMenuThingsAndStuff.Instance.ShowOkPopup("Steam not detected. Please restart the game with Steam running", () => { });
+ }
+
+ private void CleanUI()
+ {
+ GameObject.Destroy(this.FindChildByName("Text Content"));
+
+ GameObject.Destroy(this.FindChildByName("HardcoreSavingBanner"));
+ GameObject.Destroy(this.FindChildByName("TutorialSavingBanner"));
+
+ GameObject.Destroy(this.FindChildByName("Thumbnail"));
+
+ GameObject.Destroy(this.FindChildByName("ButtonIcon OpenFolder"));
+ GameObject.Destroy(this.FindChildByName("ButtonIcon Rename"));
+ GameObject.Destroy(this.FindChildByName("ButtonTextIcon Load"));
+
+ }
+ private void BuildUI()
+ {
+
+ // Update title
+ GameObject titleObj = this.FindChildByName("Title");
+ GameObject.Destroy(titleObj.GetComponentInChildren());
+ titleObj.GetComponentInChildren().key = Locale.SERVER_BROWSER__TITLE_KEY;
+ titleObj.GetComponentInChildren().UpdateLocalization();
+
+ //Rebuild the save description pane
+ GameObject serverWindowGO = this.FindChildByName("Save Description");
+ GameObject serverNameGO = serverWindowGO.FindChildByName("text list [noloc]");
+ GameObject scrollViewGO = this.FindChildByName("Scroll View");
+
+ //Create new objects
+ GameObject serverScroll = Instantiate(scrollViewGO, serverNameGO.transform.position, Quaternion.identity, serverWindowGO.transform);
+
+
+ /*
+ * Setup server name
+ */
+ serverNameGO.name = "Server Title";
+
+ //Positioning
+ RectTransform serverNameRT = serverNameGO.GetComponent();
+ serverNameRT.pivot = new Vector2(1f, 1f);
+ serverNameRT.anchorMin = new Vector2(0f, 1f);
+ serverNameRT.anchorMax = new Vector2(1f, 1f);
+ serverNameRT.SetInsetAndSizeFromParentEdge(RectTransform.Edge.Top, 0, 54);
+
+ //Text
+ serverName = serverNameGO.GetComponentInChildren();
+ serverName.alignment = TextAlignmentOptions.Center;
+ serverName.textWrappingMode = TextWrappingModes.Normal;
+ serverName.fontSize = 22;
+ serverName.text = Locale.SERVER_BROWSER__INFO_TITLE;// "Server Browser Info";
+
+ /*
+ * Setup server details
+ */
+
+ // Create new ScrollRect object
+ GameObject viewport = serverScroll.FindChildByName("Viewport");
+ serverScroll.transform.SetParent(serverWindowGO.transform, false);
+
+ // Positioning ScrollRect
+ RectTransform serverScrollRT = serverScroll.GetComponent();
+ serverScrollRT.pivot = new Vector2(1f, 1f);
+ serverScrollRT.anchorMin = new Vector2(0f, 1f);
+ serverScrollRT.anchorMax = new Vector2(1f, 1f);
+ serverScrollRT.localEulerAngles = Vector3.zero;
+ serverScrollRT.SetInsetAndSizeFromParentEdge(RectTransform.Edge.Top, 54, 400);
+ serverScrollRT.SetInsetAndSizeFromParentEdge(RectTransform.Edge.Left, 0, serverNameGO.GetComponent().rect.width);
+
+ RectTransform viewportRT = viewport.GetComponent();
+
+ // Assign Viewport to ScrollRect
+ ScrollRect scrollRect = serverScroll.GetComponent();
+ scrollRect.viewport = viewportRT;
+
+ // Create Content
+ GameObject.Destroy(serverScroll.FindChildByName("GRID VIEW").gameObject);
+ GameObject content = new("Content", typeof(RectTransform), typeof(ContentSizeFitter), typeof(VerticalLayoutGroup));
+ detailsContent = content.transform;
+ detailsContent.SetParent(viewport.transform, false);
+ ContentSizeFitter contentSF = content.GetComponent();
+ contentSF.verticalFit = ContentSizeFitter.FitMode.PreferredSize;
+ VerticalLayoutGroup contentVLG = content.GetComponent();
+ contentVLG.childControlWidth = true;
+ contentVLG.childControlHeight = true;
+ RectTransform contentRT = content.GetComponent();
+ contentRT.pivot = new Vector2(0f, 1f);
+ contentRT.anchorMin = new Vector2(0f, 1f);
+ contentRT.anchorMax = new Vector2(1f, 1f);
+ contentRT.offsetMin = Vector2.zero;
+ contentRT.offsetMax = Vector2.zero;
+ scrollRect.content = contentRT;
+ contentRT.localPosition = new Vector3(contentRT.localPosition.x + 10, contentRT.localPosition.y, contentRT.localPosition.z);
+
+ // Create TextMeshProUGUI object
+ GameObject textGO = new("Details Text", typeof(TextMeshProUGUI));
+ textGO.transform.SetParent(contentRT.transform, false);
+ detailsPane = textGO.GetComponent();
+ detailsPane.textWrappingMode = TextWrappingModes.Normal;
+ detailsPane.fontSize = 18;
+ detailsPane.text = Locale.Get(Locale.SERVER_BROWSER__INFO_CONTENT_KEY, [AUTO_REFRESH_TIME, REFRESH_MIN_TIME]);// "Welcome to Derail Valley Multiplayer Mod!
The server list refreshes automatically every 30 seconds, but you can refresh manually once every 10 seconds.";
+
+ SetupModsGroup();
+
+ // Adjust text RectTransform to fit content
+ RectTransform textRT = textGO.GetComponent();
+ textRT.pivot = new Vector2(0.5f, 1f);
+ textRT.anchorMin = new Vector2(0, 1);
+ textRT.anchorMax = new Vector2(1, 1);
+ textRT.offsetMin = new Vector2(0, -detailsPane.preferredHeight);
+ textRT.offsetMax = new Vector2(0, 0);
+
+ // Set content size to fit text
+ contentRT.sizeDelta = new Vector2(contentRT.sizeDelta.x - 50, detailsPane.preferredHeight);
+
+ // Update buttons on the multiplayer pane
+ GameObject goDirectIP = this.gameObject.UpdateButton("ButtonTextIcon Overwrite", "ButtonTextIcon Manual", Locale.SERVER_BROWSER__MANUAL_CONNECT_KEY, null, Multiplayer.AssetIndex.multiplayerIcon);
+ GameObject goJoin = this.gameObject.UpdateButton("ButtonTextIcon Save", "ButtonTextIcon Join", Locale.SERVER_BROWSER__JOIN_KEY, null, Multiplayer.AssetIndex.connectIcon);
+ GameObject goRefresh = this.gameObject.UpdateButton("ButtonIcon Delete", "ButtonIcon Refresh", Locale.SERVER_BROWSER__REFRESH_KEY, null, Multiplayer.AssetIndex.refreshIcon);
+
+
+ if (goDirectIP == null || goJoin == null || goRefresh == null)
+ {
+ Multiplayer.LogError("One or more buttons not found.");
+ return;
+ }
+
+ // Set up event listeners
+ buttonDirectIP = goDirectIP.GetComponent();
+ buttonDirectIP.onClick.AddListener(DirectAction);
+
+ buttonJoin = goJoin.GetComponent();
+ buttonJoin.onClick.AddListener(JoinAction);
+
+ buttonRefresh = goRefresh.GetComponent();
+ buttonRefresh.onClick.AddListener(RefreshAction);
+
+ //Lock out the join button until a server has been selected
+ buttonJoin.ToggleInteractable(false);
+ }
+
+ private void SetupServerBrowser()
+ {
+ GameObject GridviewGO = this.FindChildByName("Scroll View").FindChildByName("GRID VIEW");
+
+ //Disable before we make any changes
+ GridviewGO.SetActive(false);
+
+
+ //load our custom controller
+ SaveLoadGridView slgv = GridviewGO.GetComponent();
+ serverGridView = GridviewGO.AddComponent();
+
+ //grab the original prefab
+ slgv.viewElementPrefab.SetActive(false);
+ serverGridView.viewElementPrefab = Instantiate(slgv.viewElementPrefab);
+ slgv.viewElementPrefab.SetActive(true);
+
+ //Remove original controller
+ GameObject.Destroy(slgv);
+
+ //Don't forget to re-enable!
+ GridviewGO.SetActive(true);
+ serverGridView.Clear();
+ }
+
+ private void SetupModsGroup()
+ {
+ ManualController manualController = MainMenuControllerPatch.MainMenuControllerInstance.GetComponentInChildren(true);
+ if (manualController == null)
+ {
+ Multiplayer.LogError("SetupModsGroup() ManualController not found");
+ return;
+ }
+
+ navigationButtonPrefab = manualController.navigationButtonPrefab;
+
+ elementRequiredMods = CreateModElement($"{FORMAT_ALPHA}{Locale.SERVER_BROWSER__REQUIRED_MODS}");
+ elementRequiredMods.name = "Required Mods";
+ elementRequiredMods.Collapse(true);
+
+ elementExtraMods = CreateModElement($"{FORMAT_ALPHA}{Locale.SERVER_BROWSER__EXTRA_MODS}");
+ elementExtraMods.name = "Extra Mods";
+ elementExtraMods.Collapse(true);
+ }
+
+ private CollapsibleElement CreateModElement(string label, CollapsibleElement parent = null)
+ {
+ // Container for required mods
+ RectTransform rt = Instantiate(navigationButtonPrefab, detailsContent).GetComponent();
+
+ CollapsibleElement element = rt.GetComponent();
+ CollapsibleElementVisualController controller = rt.GetComponent();
+
+ controller.categoryTextColor = Color.white;
+ controller.articleTextColor = Color.white;
+ controller.collapseIndicatorImage.color = new(1f, 1f, 1f, 0x50 / 255f);
+
+ element.SetText(label);
+
+ if (parent != null)
+ {
+ var last = parent.childElements.LastOrDefault() ?? parent;
+ element.transform.SetSiblingIndex(last.transform.GetSiblingIndex() + 1);
+ parent.AddChild(element);
+
+ // Remove the Button to allow the hyperlink handler to work
+ Component.Destroy(element.GetComponentInChildren(true));
+
+ //// Enable hyperlink parsing
+ HyperlinkHandler modHyperlinkHandler = controller.elementText.GetOrAddComponent();
+ modHyperlinkHandler.linkColor = new UnityEngine.Color(0.302f, 0.651f, 1f); // #4DA6FF
+ modHyperlinkHandler.linkHoverColor = new UnityEngine.Color(0.498f, 0.749f, 1f); // #7FBFFF
+ modHyperlinkHandler.ApplyLinkStyling();
+ }
+
+ return element;
+ }
+
+ private void ClearModElements(CollapsibleElement parent)
+ {
+ if (parent == null)
+ return;
+
+ parent.Collapse(true);
+
+ if (parent.childElements.Count == 0)
+ return;
+
+ foreach (var element in parent.childElements)
+ GameObject.Destroy(element.gameObject);
+
+ parent.childElements.Clear();
+ }
+
+ private void CollapsibleElementClicked(CollapsibleElement element)
+ {
+ element.Toggle();
+ }
+
+ private void SetupListeners(bool on)
+ {
+ if (on)
+ {
+ serverGridView.SelectedIndexChanged += this.OnSelectedIndexChanged;
+ elementRequiredMods.CollapsibleElementClicked += CollapsibleElementClicked;
+ elementExtraMods.CollapsibleElementClicked += CollapsibleElementClicked;
+ }
+ else
+ {
+ serverGridView.SelectedIndexChanged -= this.OnSelectedIndexChanged;
+ elementRequiredMods.CollapsibleElementClicked -= CollapsibleElementClicked;
+ elementExtraMods.CollapsibleElementClicked -= CollapsibleElementClicked;
+ }
+ }
+ #endregion
+
+ #region UI callbacks
+ private void RefreshAction()
+ {
+ if (serverRefreshing)
+ return;
+
+ remoteServers.Clear();
+
+ serverRefreshing = true;
+ //buttonJoin.ToggleInteractable(false);
+ buttonRefresh.ToggleInteractable(false);
+
+ if (DVSteamworks.Success)
+ ListActiveLobbies();
+
+ }
+ private void JoinAction()
+ {
+ if (selectedServer == null || connectionState != ConnectionState.NotConnected)
+ return;
+
+ buttonDirectIP.ToggleInteractable(false);
+ buttonJoin.ToggleInteractable(false);
+
+ //not making a direct connection
+ direct = false;
+ portNumber = -1;
+
+ var lobby = GetLobbyFromServer(selectedServer);
+ if (lobby != null)
+ {
+ selectedLobby = (Lobby)lobby;
+ _ = JoinLobby((Lobby)selectedLobby);
+ }
+ else
+ {
+ Multiplayer.LogWarning($"JoinAction called but lobby is null");
+ AttemptFail();
+ }
+ }
+
+ private void DirectAction()
+ {
+ if (connectionState != ConnectionState.NotConnected)
+ return;
+
+ buttonDirectIP.ToggleInteractable(false);
+ buttonJoin.ToggleInteractable(false);
+
+ //making a direct connection
+ direct = true;
+ password = null;
+
+ ShowIpPopup();
+ }
+
+ private void OnSelectedIndexChanged(MPGridView gridView)
+ {
+ if (serverRefreshing)
+ return;
+
+ selectedServer = gridView.SelectedItem;
+ if (selectedServer != null && incompatibleMods == false)
+ {
+ UpdateDetailsPane();
+
+ // Check if we can connect to this server
+ Multiplayer.Log($"Server: \"{selectedServer.GameVersion}\" \"{selectedServer.MultiplayerVersion}\"");
+ Multiplayer.Log($"Client: \"{MainMenuControllerPatch.MenuProvider.BuildVersionString}\" \"{Multiplayer.Ver}\"");
+ Multiplayer.Log($"Result: \"{selectedServer.GameVersion == MainMenuControllerPatch.MenuProvider.BuildVersionString}\" \"{selectedServer.MultiplayerVersion == Multiplayer.Ver}\"");
+
+ bool canConnect = selectedServer.GameVersion == MainMenuControllerPatch.MenuProvider.BuildVersionString &&
+ selectedServer.MultiplayerVersion == Multiplayer.Ver;
+
+ buttonJoin.ToggleInteractable(canConnect);
+ }
+ else
+ {
+ buttonJoin.ToggleInteractable(false);
+ }
+ }
+
+ private void UpdateElement(IServerBrowserGameDetails element)
+ {
+ int index = serverGridView.IndexOf(element);
+
+ if (index >= 0)
+ {
+ var viewElement = serverGridView.GetElementAt(index) as ServerBrowserElement;
+ viewElement?.UpdateView();
+ }
+ }
+ #endregion
+
+ private void UpdateDetailsPane()
+ {
+ StringBuilder details = new();
+
+ if (selectedServer != null)
+ {
+ serverName.text = selectedServer.Name;
+
+ // Note: built-in localisations have a trailing colon e.g. 'Game mode:'
+
+ details.Append(FORMAT_ALPHA + LocalizationAPI.L("launcher/game_mode", []) + " " + LobbyServerData.GetGameModeFromInt(selectedServer.GameMode) + "
");
+ details.Append(FORMAT_ALPHA + LocalizationAPI.L("launcher/difficulty", []) + " " + LobbyServerData.GetDifficultyFromInt(selectedServer.Difficulty) + "
");
+ details.Append(FORMAT_ALPHA + LocalizationAPI.L("launcher/in_game_time_passed", []) + " " + selectedServer.TimePassed + "
");
+ details.Append(FORMAT_ALPHA + Locale.SERVER_BROWSER__PLAYERS + ": " + selectedServer.CurrentPlayers + '/' + selectedServer.MaxPlayers + "
");
+ details.Append(FORMAT_ALPHA + Locale.SERVER_BROWSER__PASSWORD_REQUIRED + ": " + (selectedServer.HasPassword ? Locale.SERVER_BROWSER__PASSWORD_REQUIRED_YES : Locale.SERVER_BROWSER__PASSWORD_REQUIRED_NO) + "
");
+ details.Append(FORMAT_ALPHA + Locale.SERVER_BROWSER__GAME_VERSION + ": " + (selectedServer.GameVersion != MainMenuControllerPatch.MenuProvider.BuildVersionString ? "" : "") + selectedServer.GameVersion + "
");
+
+ details.Append(selectedServer.ServerDetails);
+
+ if (selectedServer.ServerDetails != null && selectedServer.ServerDetails.Length > 0)
+ details.Append("
");
+
+ detailsPane.text = details.ToString();
+
+ // Build mod lists
+ ClearModElements(elementRequiredMods);
+ ClearModElements(elementExtraMods);
+
+ var localMods = ModCompatibilityManager.Instance.GetLocalMods();
+
+ BuildServerMods(selectedServer.RequiredMods, localMods);
+ BuildLocalMods(localMods);
+ }
+ else
+ {
+ serverName.text = Locale.SERVER_BROWSER__INFO_TITLE;// "Server Browser Info";
+ detailsPane.text = Locale.Get(Locale.SERVER_BROWSER__INFO_CONTENT_KEY, [AUTO_REFRESH_TIME, REFRESH_MIN_TIME]);// "Welcome to Derail Valley Multiplayer Mod!
The server list refreshes automatically every 30 seconds, but you can refresh manually once every 10 seconds.";
+
+ ClearModElements(elementRequiredMods);
+ ClearModElements(elementExtraMods);
+ }
+
+ }
+
+ ///
+ /// Validates the client has all required mods for the server and the versions match.
+ /// Populates the mod details list.
+ ///
+ ///
+ ///
+ ///
+ /// true if all required mods are present and have correct versions, false if any mods are missing or there is a version mismatch.
+ private bool BuildServerMods(ModInfo[] serverMods, ModInfo[] localMods)
+ {
+ bool modsOk = true;
+
+ if (serverMods == null || localMods == null)
+ {
+ Multiplayer.LogWarning("BuildServerMods() called with null serverMods or localMods");
+ return false;
+ }
+
+ if (selectedServer.RequiredMods != null && selectedServer.RequiredMods.Length > 0)
+ {
+ Multiplayer.LogDebug(() => $"Parsed {serverMods?.Length} mods from server \"{selectedServer?.Name}\"");
+
+ foreach (var mod in serverMods)
+ {
+ ModInfo modMatch = localMods.FirstOrDefault(l => l.Id == mod.Id);
+
+ Multiplayer.LogDebug(() => $"Checking mod \"{mod.Id}\" v\"{mod.Version}\" - Found: \"{modMatch.Id}\" v\"{modMatch.Version}\"");
+
+ bool modFound = modMatch.Id == mod.Id;
+ bool modVersionMatch = modFound && modMatch.Version == mod.Version;
+
+ modsOk &= modVersionMatch;
+
+ string status;
+ if (modFound && modVersionMatch)
+ status = $"{Locale.SERVER_BROWSER__OK}";
+ else if (modFound && !modVersionMatch)
+ status = $"{Locale.SERVER_BROWSER__MISMATCH}";
+ else
+ status = $"{Locale.SERVER_BROWSER__MISSING}";
+
+ var link = !string.IsNullOrEmpty(mod.Url) ? $"{mod.Id}" : mod.Id;
+
+ var element = CreateModElement(mod.Id, elementRequiredMods);
+ element.isLeaf = true;
+ element.SetText($"{link} ({mod.Version}) - {status}");
+ }
+
+ elementRequiredMods.Expand(false);
+
+ if (modsOk)
+ {
+ elementRequiredMods.Collapse(false);
+ elementExtraMods.SetText($"{FORMAT_ALPHA}{Locale.SERVER_BROWSER__REQUIRED_MODS}: {Locale.SERVER_BROWSER__OK}");
+ }
+ else
+ {
+ elementRequiredMods.SetText($"{FORMAT_ALPHA}{Locale.SERVER_BROWSER__REQUIRED_MODS}");
+ }
+ }
+
+ return modsOk;
+ }
+
+ ///
+ /// Validates the client does not have any mods that the server is not running and does not have any mods incompatible with Multiplayer.
+ /// Populates the mod details list.
+ ///
+ ///
+ ///
+ ///
+ /// true if there are no conflicting mods, false if any mods can not be used with this server.
+ private bool BuildLocalMods(ModInfo[] localMods)
+ {
+ bool modsOk = true;
+
+ if (localMods == null || selectedServer?.RequiredMods == null)
+ {
+ Multiplayer.LogWarning($"BuildLocalMods() localMods is null: {localMods == null}, requiredMods is null: {selectedServer?.RequiredMods == null}");
+ return false;
+ }
+
+ var extraMods = localMods.Where(l => !selectedServer.RequiredMods.Any(m => m.Id == l.Id)).ToArray();
+ Multiplayer.LogDebug(() => $"Found {extraMods.Length} extra mods on client for server \"{selectedServer.Name}\"");
+
+ if (extraMods.Length > 0)
+ {
+ string status;
+ foreach (var mod in extraMods)
+ {
+ var compatibility = ModCompatibilityManager.Instance.GetCompatibility(mod);
+ if (compatibility == MultiplayerCompatibility.Incompatible)
+ {
+ status = $"{Locale.SERVER_BROWSER__INCOMPATIBLE}";
+ modsOk = false;
+ }
+ else if (compatibility == MultiplayerCompatibility.Undefined || compatibility == MultiplayerCompatibility.All)
+ {
+ status = $"{Locale.SERVER_BROWSER__EXTRA_MOD}";
+ modsOk = false;
+ }
+ else
+ {
+ status = $"{Locale.SERVER_BROWSER__OK}";
+ }
+
+ var element = CreateModElement(mod.Id, elementExtraMods);
+ element.isLeaf = true;
+ element.SetText($"{mod.Id} ({mod.Version}) - {status}");
+ }
+
+ elementExtraMods.Expand(false);
+
+ if (modsOk)
+ {
+ elementExtraMods.Collapse(false);
+ elementExtraMods.SetText($"{FORMAT_ALPHA}{Locale.SERVER_BROWSER__EXTRA_MODS}: {Locale.SERVER_BROWSER__OK}");
+ }
+ else
+ {
+ elementExtraMods.SetText($"{FORMAT_ALPHA}{Locale.SERVER_BROWSER__EXTRA_MODS}");
+ }
+ }
+
+ return modsOk;
+ }
+
+ private void ShowIpPopup()
+ {
+ var popup = MainMenuThingsAndStuff.Instance.ShowRenamePopup();
+ if (popup == null)
+ {
+ Multiplayer.LogError("Popup not found.");
+ return;
+ }
+
+ popup.labelTMPro.text = Locale.SERVER_BROWSER__IP;
+ popup.GetComponentInChildren().text = Multiplayer.Settings.LastRemoteIP;
+
+ popup.Closed += result =>
+ {
+ if (result.closedBy == PopupClosedByAction.Abortion)
+ {
+ buttonDirectIP.ToggleInteractable(true);
+ OnSelectedIndexChanged(serverGridView); //re-enable the join button if a valid gridview item is selected
+ return;
+ }
+
+ if (!IPAddress.TryParse(result.data, out IPAddress parsedAddress))
+ {
+ string inputUrl = result.data;
+
+ if (!inputUrl.StartsWith("http://") && !inputUrl.StartsWith("https://"))
+ {
+ inputUrl = "http://" + inputUrl;
+ }
+
+ bool isValidURL = Uri.TryCreate(inputUrl, UriKind.Absolute, out Uri uriResult)
+ && (uriResult.Scheme == Uri.UriSchemeHttp || uriResult.Scheme == Uri.UriSchemeHttps);
+
+ if (isValidURL)
+ {
+ string domainName = ExtractDomainName(result.data);
+ try
+ {
+ IPHostEntry hostEntry = Dns.GetHostEntry(domainName);
+ IPAddress[] addresses = hostEntry.AddressList;
+
+ if (addresses.Length > 0)
+ {
+ string address2 = addresses[0].ToString();
+
+ address = address2;
+ Multiplayer.Log(address);
+
+ ShowPortPopup();
+ return;
+ }
+ }
+ catch (Exception ex)
+ {
+ Multiplayer.LogError($"An error occurred: {ex.Message}");
+ }
+ }
+
+ MainMenuThingsAndStuff.Instance.ShowOkPopup(Locale.SERVER_BROWSER__IP_INVALID, ShowIpPopup);
+ }
+ else
+ {
+ if (parsedAddress.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork)
+ connectionState = ConnectionState.AttemptingIPv4;
+ else
+ connectionState = ConnectionState.AttemptingIPv6;
+
+ address = result.data;
+ ShowPortPopup();
+ }
+ };
+ }
+
+ private void ShowPortPopup()
+ {
+
+ var popup = MainMenuThingsAndStuff.Instance.ShowRenamePopup();
+ if (popup == null)
+ {
+ Multiplayer.LogError("Popup not found.");
+ return;
+ }
+
+ popup.labelTMPro.text = Locale.SERVER_BROWSER__PORT;
+ popup.GetComponentInChildren().text = $"{Multiplayer.Settings.LastRemotePort}";
+ popup.GetComponentInChildren().contentType = TMP_InputField.ContentType.IntegerNumber;
+ popup.GetComponentInChildren().characterLimit = MAX_PORT_LEN;
+
+ popup.Closed += result =>
+ {
+ if (result.closedBy == PopupClosedByAction.Abortion)
+ {
+ buttonDirectIP.ToggleInteractable(true);
+ return;
+ }
+
+ if (!int.TryParse(result.data, out portNumber) || portNumber < MIN_PORT || portNumber > MAX_PORT)
+ {
+ MainMenuThingsAndStuff.Instance.ShowOkPopup(Locale.SERVER_BROWSER__PORT_INVALID, ShowIpPopup);
+ }
+ else
+ {
+ ShowPasswordPopup();
+ }
+ };
+ }
+
+ private void ShowPasswordPopup()
+ {
+ var popup = MainMenuThingsAndStuff.Instance.ShowRenamePopup();
+ if (popup == null)
+ {
+ Multiplayer.LogError("Popup not found.");
+ return;
+ }
+
+ popup.labelTMPro.text = Locale.SERVER_BROWSER__PASSWORD;
+
+ //direct IP connection
+ if (direct)
+ {
+ //Prefill with stored password
+ popup.GetComponentInChildren().text = Multiplayer.Settings.LastRemotePassword;
+
+ //Set us up to allow a blank password
+ DestroyImmediate(popup.GetComponentInChildren());
+ popup.GetOrAddComponent();
+ }
+
+ popup.Closed += result =>
+ {
+ if (result.closedBy == PopupClosedByAction.Abortion)
+ {
+ AttemptFail();
+ return;
+ }
+
+ password = result.data;
+
+ if (direct)
+ {
+ //store params for later
+ Multiplayer.Settings.LastRemoteIP = address;
+ Multiplayer.Settings.LastRemotePort = portNumber;
+ Multiplayer.Settings.LastRemotePassword = result.data;
+ }
+
+ InitiateConnection();
+ };
+ }
+
+ public void ShowConnectingPopup()
+ {
+ var popup = MainMenuThingsAndStuff.Instance.ShowOkPopup();
+
+ if (popup == null)
+ {
+ Multiplayer.LogError("ShowConnectingPopup() Popup not found.");
+ return;
+ }
+
+ connectingPopup = popup;
+
+ Localize loc = popup.positiveButton.GetComponentInChildren();
+ loc.key = "cancel";
+ loc.UpdateLocalization();
+
+
+ popup.labelTMPro.text = $"Connecting, please wait..."; //to be localised
+
+ popup.Closed += (PopupResult result) =>
+ {
+ connectionState = ConnectionState.Aborted;
+ };
+
+ }
+
+ #region workflow
+ private void UpdatePings()
+ {
+ UpdatePingsSteam();
+ }
+
+ private void InitiateConnection()
+ {
+
+ Multiplayer.Log($"Initiating connection. Direct: {direct}, Address: {address}, Lobby: {selectedLobby?.Id.ToString()}");
+
+ attempt = 0;
+ ShowConnectingPopup();
+
+ if (!direct && joinedLobby != null)
+ {
+ connectionState = ConnectionState.AttemptingSteamRelay;
+ string hostId = ((Lobby)joinedLobby).Owner.Id.Value.ToString();
+ NetworkLifecycle.Instance.StartClient(hostId, -1, password, false, OnDisconnect);
+ return;
+ }
+
+ Multiplayer.Log($"AttemptConnection address: {address}");
+
+ if (IPAddress.TryParse(address, out IPAddress IPaddress))
+ {
+ Multiplayer.Log($"AttemptConnection tryParse: {IPaddress.AddressFamily}");
+
+ if (IPaddress.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork)
+ {
+ AttemptIPv4();
+ }
+ else if (IPaddress.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6)
+ {
+ AttemptIPv6();
+ }
+ }
+ else
+ {
+ Multiplayer.LogError($"IP address invalid: {address}");
+ AttemptFail();
+ }
+ }
+
+ private void AttemptIPv6()
+ {
+ Multiplayer.Log($"AttemptIPv6() {address}");
+
+ if (connectionState == ConnectionState.Aborted)
+ return;
+
+ attempt++;
+ if (connectingPopup != null)
+ connectingPopup.labelTMPro.text = $"Connecting, please wait...\r\nAttempt: {attempt}";
+
+ Multiplayer.Log($"AttemptIPv6() starting attempt");
+ connectionState = ConnectionState.AttemptingIPv6;
+ SingletonBehaviour.Instance.StartClient(address, portNumber, password, false, OnDisconnect);
+
+ }
+
+ //private void AttemptIPv6Punch()
+ //{
+ // Multiplayer.Log($"AttemptIPv6Punch() {address}");
+
+ // if (connectionState == ConnectionState.Aborted)
+ // return;
+
+ // attempt++;
+ // if (connectingPopup != null)
+ // connectingPopup.labelTMPro.text = $"Connecting, please wait...\r\nAttempt: {attempt}";
+
+ // //punching not implemented we'll just try again for now
+ // connectionState = ConnectionState.AttemptingIPv6Punch;
+ // SingletonBehaviour.Instance.StartClient(address, portNumber, password, false, OnDisconnect);
+
+ //}
+ private void AttemptIPv4()
+ {
+ Multiplayer.Log($"AttemptIPv4() {address}, {connectionState}");
+
+ if (connectionState == ConnectionState.Aborted)
+ return;
+
+ attempt++;
+ if (connectingPopup != null)
+ connectingPopup.labelTMPro.text = $"Connecting, please wait...\r\nAttempt: {attempt}";
+
+ if (!direct)
+ {
+ if (selectedServer.ipv4 == null || selectedServer.ipv4 == string.Empty)
+ {
+ AttemptFail();
+ return;
+ }
+
+ address = selectedServer.ipv4;
+ }
+
+ Multiplayer.Log($"AttemptIPv4() {address}");
+
+ if (IPAddress.TryParse(address, out IPAddress IPaddress))
+ {
+ Multiplayer.Log($"AttemptIPv4() TryParse passed");
+ if (IPaddress.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork)
+ {
+ Multiplayer.Log($"AttemptIPv4() starting attempt");
+ connectionState = ConnectionState.AttemptingIPv4;
+ SingletonBehaviour.Instance.StartClient(address, portNumber, password, false, OnDisconnect);
+ return;
+ }
+ }
+
+ Multiplayer.Log($"AttemptIPv4() TryParse failed");
+ AttemptFail();
+ string message = "Host Unreachable";
+ MainMenuThingsAndStuff.Instance.ShowOkPopup(message, () => { });
+ }
+
+ //private void AttemptIPv4Punch()
+ //{
+ // Multiplayer.Log($"AttemptIPv4Punch() {address}");
+
+ // if (connectionState == ConnectionState.Aborted)
+ // return;
+
+ // attempt++;
+ // if (connectingPopup != null)
+ // connectingPopup.labelTMPro.text = $"Connecting, please wait...\r\nAttempt: {attempt}";
+
+ // //punching not implemented we'll just try again for now
+ // connectionState = ConnectionState.AttemptingIPv4Punch;
+ // SingletonBehaviour.Instance.StartClient(address, portNumber, password, false, OnDisconnect);
+ //}
+
+ private void AttemptFail()
+ {
+ connectionState = ConnectionState.Failed;
+
+ if (connectingPopup != null)
+ {
+ connectingPopup.RequestClose(PopupClosedByAction.Abortion, null);
+ connectingPopup = null; // Clear the reference
+ }
+
+ joinedLobby?.Leave();
+ joinedLobby = null;
+
+ if (gameObject != null && gameObject.activeInHierarchy)
+ {
+ if (serverGridView != null)
+ OnSelectedIndexChanged(serverGridView);
+
+ if (buttonDirectIP != null && buttonDirectIP.gameObject != null)
+ buttonDirectIP.ToggleInteractable(true);
+ }
+
+ StartCoroutine(ResetConnectionState());
+ }
+
+ private IEnumerator ResetConnectionState()
+ {
+ yield return new WaitForSeconds(1.0f);
+ connectionState = ConnectionState.NotConnected;
+ }
+
+ private void OnDisconnect(DisconnectReason reason, string message)
+ {
+ Multiplayer.Log($"Disconnected due to: {reason}, \"{message}\"");
+
+ string displayMessage = !string.IsNullOrEmpty(message)
+ ? message
+ : GetDisplayMessageForDisconnect(reason);
+
+ Multiplayer.LogDebug(() => "OnDisconnect() Leaving Lobby");
+ joinedLobby?.Leave();
+ joinedLobby = null;
+
+ connectionState = ConnectionState.NotConnected;
+ AttemptFail();
+
+ NetworkLifecycle.Instance.QueueMainMenuEvent(() =>
+ {
+ Multiplayer.LogDebug(() => "OnDisconnect() Queuing");
+ MainMenuThingsAndStuff.Instance?.ShowOkPopup(displayMessage, () => { });
+ });
+ }
+
+ private string GetDisplayMessageForDisconnect(DisconnectReason reason)
+ {
+ return reason switch
+ {
+ DisconnectReason.UnknownHost => Locale.DISCONN_REASON__UNKNOWN_HOST, //"Unknown Host",
+ DisconnectReason.DisconnectPeerCalled => Locale.DISCONN_REASON__PLAYER_KICKED, //"Player Kicked",
+ DisconnectReason.ConnectionFailed => Locale.DISCONN_REASON__HOST_UNREACHABLE, //"Host Unreachable",
+ DisconnectReason.ConnectionRejected => Locale.DISCONN_REASON__REJECTED, //"Rejected!",
+ DisconnectReason.RemoteConnectionClose => Locale.DISCONN_REASON__SHUTTING_DOWN, //"Server Shutting Down",
+ DisconnectReason.Timeout => Locale.DISCONN_REASON__HOST_TIMED_OUT, //"Server Timed Out",
+ _ => "Connection Failed"
+ };
+ }
+ #endregion
+
+
+ #region steam lobby
+ private async void ListActiveLobbies()
+ {
+ lobbies = await SteamMatchmaking.LobbyList.WithMaxResults(100)
+ .FilterDistanceWorldwide()
+ .WithSlotsAvailable(-1)
+ //.WithKeyValue(SteamworksUtils.MP_MOD_KEY, string.Empty)
+ .RequestAsync();
+
+ Multiplayer.LogDebug(() => $"ListActiveLobbies() lobbies found: {lobbies?.Count()}");
+
+ remoteServers.Clear();
+
+ if (lobbies != null)
+ {
+ var myLoc = SteamNetworkingUtils.LocalPingLocation;
+
+ foreach (var lobby in lobbies)
+ {
+ LobbyServerData server = SteamworksUtils.GetLobbyData(lobby);
+
+ server.id = lobby.Id.ToString();
+
+ server.CurrentPlayers = lobby.MemberCount;
+ server.MaxPlayers = lobby.MaxMembers;
+
+ remoteServers.Add(server);
+
+ Multiplayer.LogDebug(() => $"ListActiveLobbies() lobby {server.Name}, {lobby.MemberCount}/{lobby.MaxMembers}");
+
+ }
+ }
+ remoteRefreshComplete = true;
+ }
+
+ private void UpdatePingsSteam()
+ {
+ foreach (var server in serverGridView.Items)
+ {
+ if (server is LobbyServerData lobbyServer)
+ {
+ if (ulong.TryParse(server.id, out ulong id))
+ {
+ Lobby? lobby = lobbies.FirstOrDefault(l => l.Id.Value == id);
+ if (lobby != null)
+ {
+ string strLoc = ((Lobby)lobby).GetData(SteamworksUtils.LOBBY_NET_LOCATION_KEY);
+ NetPingLocation? location = NetPingLocation.TryParseFromString(strLoc);
+
+ if (location != null)
+ server.Ping = SteamNetworkingUtils.EstimatePingTo((NetPingLocation)location) / 2; //normalise to one way ping
+ }
+ }
+
+ UpdateElement(lobbyServer);
+ }
+ }
+ }
+
+ private Lobby? GetLobbyFromServer(IServerBrowserGameDetails server)
+ {
+ if (ulong.TryParse(server.id, out ulong id))
+ return lobbies.FirstOrDefault(l => l.Id.Value == id);
+
+ return null;
+ }
+ #endregion
+
+ private void RefreshGridView()
+ {
+ // Get all active IDs
+ List activeIDs = remoteServers.Select(s => s.id).Distinct().ToList();
+
+ // Remove servers that no longer exist
+ for (int i = serverGridView.Items.Count - 1; i >= 0; i--)
+ {
+ if (!activeIDs.Contains(serverGridView.Items[i].id))
+ {
+ serverGridView.RemoveItemAt(i);
+ }
+ }
+
+ Multiplayer.LogDebug(() => $"RefreshGridView() prepare to update/add, remoteServers count: {remoteServers.Count}");
+ // Update existing servers and add new ones
+ foreach (var server in remoteServers)
+ {
+ var existingServer = serverGridView.Items.FirstOrDefault(gv => gv.id == server.id);
+ if (existingServer != null)
+ {
+ Multiplayer.LogDebug(() => $"RefreshGridView() updating server");
+ // Update existing server
+ existingServer.TimePassed = server.TimePassed;
+ existingServer.CurrentPlayers = server.CurrentPlayers;
+ existingServer.LocalIPv4 = server.LocalIPv4;
+ existingServer.LastSeen = server.LastSeen;
+ }
+ else
+ {
+ Multiplayer.LogDebug(() => $"RefreshGridView() adding server");
+ // Add new server
+ serverGridView.AddItem(server);
+ }
+ }
+ }
+
+ private string ExtractDomainName(string input)
+ {
+ if (input.StartsWith("http://"))
+ {
+ input = input.Substring(7);
+ }
+ else if (input.StartsWith("https://"))
+ {
+ input = input.Substring(8);
+ }
+
+ int portIndex = input.IndexOf(':');
+ if (portIndex != -1)
+ {
+ input = input.Substring(0, portIndex);
+ }
+
+ return input;
+ }
+
+ private async Task JoinLobby(Lobby lobby)
+ {
+
+ if (connectionState != ConnectionState.NotConnected)
+ {
+ Multiplayer.LogWarning($"Cannot join lobby while in state: {connectionState}");
+ return false;
+ }
+
+ connectionState = ConnectionState.JoiningLobby;
+ Multiplayer.Log($"Attempting to join lobby ({lobby.Id})");
+
+ var joinResult = await lobby.Join();
+
+ if (joinResult == RoomEnter.Success)
+ {
+ Multiplayer.Log($"Lobby joined ({lobby.Id})");
+
+ joinedLobby = lobby;
+ lobbyToJoin = null;
+
+ string hasPass = lobby.GetData(SteamworksUtils.LOBBY_HAS_PASSWORD);
+ Multiplayer.Log($"Lobby ({lobby.Id}) has password: {hasPass}");
+
+ if (string.IsNullOrEmpty(hasPass) || hasPass == "False")
+ {
+ Multiplayer.Log($"Attempting connection...");
+ InitiateConnection();
+ }
+ else
+ {
+ connectionState = ConnectionState.AwaitingPassword;
+ Multiplayer.Log($"Prompting for password...");
+ ShowPasswordPopup();
+ }
+
+ return true;
+ }
+ else
+ {
+ Multiplayer.LogDebug(() => "JoinLobby() Leaving Lobby");
+ lobby.Leave();
+ joinedLobby = null;
+ Multiplayer.Log($"Failed to join lobby: {joinResult}");
+ AttemptFail();
+ }
+
+ return false;
+ }
+}
diff --git a/Multiplayer/Components/Networking/Jobs/NetworkedJob.cs b/Multiplayer/Components/Networking/Jobs/NetworkedJob.cs
new file mode 100644
index 00000000..4ee5ecd5
--- /dev/null
+++ b/Multiplayer/Components/Networking/Jobs/NetworkedJob.cs
@@ -0,0 +1,331 @@
+using DV.Logic.Job;
+using Multiplayer.Components.Networking.World;
+using Multiplayer.Networking.Data;
+using System;
+using System.Collections.Generic;
+using UnityEngine;
+
+namespace Multiplayer.Components.Networking.Jobs;
+
+public class NetworkedJob : IdMonoBehaviour
+{
+ #region Lookup Cache
+
+ private static readonly Dictionary jobToNetworkedJob = [];
+ private static readonly Dictionary jobIdToNetworkedJob = [];
+ private static readonly Dictionary jobIdToJob = [];
+
+ public static bool Get(ushort netId, out NetworkedJob obj)
+ {
+ bool b = Get(netId, out IdMonoBehaviour rawObj);
+ obj = (NetworkedJob)rawObj;
+ return b;
+ }
+
+ public static bool TryGetJob(ushort netId, out Job obj)
+ {
+ bool b = Get(netId, out NetworkedJob networkedJob);
+ obj = b ? networkedJob.Job : null;
+ return b;
+ }
+
+ public static bool TryGetFromJob(Job job, out NetworkedJob networkedJob)
+ {
+ return jobToNetworkedJob.TryGetValue(job, out networkedJob);
+ }
+
+ public static bool TryGetFromJobId(string jobId, out NetworkedJob networkedJob)
+ {
+ return jobIdToNetworkedJob.TryGetValue(jobId, out networkedJob);
+ }
+
+ public static bool TryGetNetId(Job job, out ushort netId)
+ {
+ if (TryGetFromJob(job, out var networkedJob))
+ {
+ netId = networkedJob.NetId;
+ return true;
+ }
+
+ netId = 0;
+ return false;
+ }
+
+ #endregion
+
+ private static readonly Dictionary> pendingJobTasks = [];
+
+ protected override bool IsIdServerAuthoritative => true;
+ public enum DirtyCause
+ {
+ JobOverview,
+ JobBooklet,
+ JobReport,
+ JobState
+ }
+
+ public Job Job { get; private set; }
+ public NetworkedStationController Station { get; private set; }
+
+ private NetworkedItem _jobOverview;
+ public NetworkedItem JobOverview
+ {
+ get => _jobOverview;
+ set
+ {
+ if (value != null && value.GetTrackedItem() == null)
+ return;
+
+ _jobOverview = value;
+
+ if (value != null)
+ {
+ Cause = DirtyCause.JobOverview;
+ OnJobDirty?.Invoke(this);
+ }
+ }
+ }
+
+ private NetworkedItem _jobBooklet;
+ public NetworkedItem JobBooklet
+ {
+ get => _jobBooklet;
+ set
+ {
+ if (value != null && value.GetTrackedItem() == null)
+ return;
+
+ _jobBooklet = value;
+ if (value != null)
+ {
+ Cause = DirtyCause.JobBooklet;
+ OnJobDirty?.Invoke(this);
+ }
+ }
+ }
+ private NetworkedItem _jobReport;
+ public NetworkedItem JobReport
+ {
+ get => _jobReport;
+ set
+ {
+ if (value != null && value.GetTrackedItem() == null)
+ return;
+
+ _jobReport = value;
+ if (value != null)
+ {
+ Cause = DirtyCause.JobReport;
+ OnJobDirty?.Invoke(this);
+ }
+ }
+ }
+
+ private readonly List JobReports = [];
+
+ public Guid OwnedBy { get; set; } = Guid.Empty;
+ public JobValidator JobValidator { get; set; }
+
+ public bool ValidatorRequestSent { get; set; } = false;
+ public bool ValidatorResponseReceived { get; set; } = false;
+ public bool ValidationAccepted { get; set; } = false;
+ public ValidationType ValidationType { get; set; }
+
+ public DirtyCause Cause { get; private set; }
+
+ public Action OnJobDirty;
+
+ public List JobCars = [];
+
+ private bool tasksInitialized = false;
+
+ protected override void Awake()
+ {
+ base.Awake();
+ }
+
+ protected void Start()
+ {
+ if (Job != null)
+ {
+ AddToCache();
+ }
+ else
+ {
+ Multiplayer.LogError($"NetworkedJob Start(): Job is null for {gameObject.name}");
+ }
+ }
+
+ public void Initialize(Job job, NetworkedStationController station)
+ {
+ Job = job;
+ Station = station;
+
+ transform.SetParent(station.transform);
+
+ // Setup handlers
+ job.JobTaken += OnJobTaken;
+ job.JobAbandoned += OnJobAbandoned;
+ job.JobCompleted += OnJobCompleted;
+ job.JobExpired += OnJobExpired;
+
+ // If this is called after Start(), we need to add to cache here
+ if (gameObject.activeInHierarchy)
+ {
+ AddToCache();
+ }
+
+ // Check for any pending tasks that were added before the NetworkedJob was created
+ if (pendingJobTasks.TryGetValue(job, out var taskList) && taskList != null)
+ {
+ pendingJobTasks.Remove(job);
+
+ //Multiplayer.LogDebug(() => $"NetworkedJob.Initialize(): Found {taskList.Count} pending tasks for jobId {job.ID}");
+
+ foreach (var task in taskList)
+ CreateNetworkedTask(task);
+ }
+
+ tasksInitialized = true;
+
+ //Multiplayer.LogDebug(() => $"NetworkedJob.Initialize(): Initialized NetworkedJob for jobId {job.ID} with {Job.tasks.Count} tasks");
+ }
+
+ private void AddToCache()
+ {
+ jobToNetworkedJob[Job] = this;
+ jobIdToNetworkedJob[Job.ID] = this;
+ jobIdToJob[Job.ID] = Job;
+ //Multiplayer.Log($"NetworkedJob added to cache: {Job.ID}");
+ }
+
+ public static void EnqueueTask(Task task, Job job)
+ {
+ if (TryGetFromJob(job, out var netJob) || netJob != null && netJob.tasksInitialized)
+ {
+ Multiplayer.LogDebug(() => $"NetworkedJob.EnqueueTask(): Creating task immediately for jobId {task.Job.ID}");
+ netJob.CreateNetworkedTask(task);
+ return;
+ }
+
+ Multiplayer.LogDebug(() => $"NetworkedJob.EnqueueTask(): Enqueuing task for later creation for jobId {task.Job.ID}");
+
+ if (!pendingJobTasks.TryGetValue(task.Job, out var taskList))
+ {
+ taskList = [];
+ pendingJobTasks[task.Job] = taskList;
+ }
+ taskList.Add(task);
+ }
+
+ public void SetTasksFromServer(Dictionary netIdToTask)
+ {
+ if (netIdToTask == null)
+ {
+ Multiplayer.LogError($"NetworkedJob.SetTasksFromServer(): netIdToTask is null for jobId {Job?.ID}");
+ return;
+ }
+
+ foreach (var kvp in netIdToTask)
+ {
+ CreateNetworkedTask(kvp.Value, kvp.Key);
+ }
+ }
+
+ private void CreateNetworkedTask(Task task, ushort netId = 0)
+ {
+ if (task == null)
+ {
+ Multiplayer.LogError($"NetworkedJob.CreateNetworkedTask(): Task is null for jobId {Job?.ID}");
+ return;
+ }
+
+ NetworkedTask taskObj = new GameObject().AddComponent();
+ taskObj.Initialize(task, netId);
+ taskObj.name = $"{Job.ID}-{taskObj.NetId}";
+ taskObj.transform.SetParent(transform);
+ }
+
+ private void OnJobTaken(Job job, bool viaLoadGame)
+ {
+ if (viaLoadGame)
+ return;
+
+ Cause = DirtyCause.JobState;
+ OnJobDirty?.Invoke(this);
+ }
+
+ private void OnJobAbandoned(Job job)
+ {
+ Cause = DirtyCause.JobState;
+ OnJobDirty?.Invoke(this);
+ }
+
+ private void OnJobCompleted(Job job)
+ {
+ Cause = DirtyCause.JobState;
+ OnJobDirty?.Invoke(this);
+ }
+
+ private void OnJobExpired(Job job)
+ {
+ Cause = DirtyCause.JobState;
+ OnJobDirty?.Invoke(this);
+ }
+
+ public void AddReport(NetworkedItem item)
+ {
+ if (item == null || !item.UsefulItem)
+ {
+ Multiplayer.LogError($"Attempted to add a null or uninitialised report: JobId: {Job?.ID}, JobNetID: {NetId}");
+ return;
+ }
+
+ Type reportType = item.TrackedItemType;
+ if (reportType == typeof(JobReport) ||
+ reportType == typeof(JobExpiredReport) ||
+ reportType == typeof(JobMissingLicenseReport) /*||
+ reportType == typeof(Debtre) ||*/
+ )
+ {
+ JobReports.Add(item);
+ Cause = DirtyCause.JobReport;
+ OnJobDirty?.Invoke(this);
+ }
+ }
+
+ public void RemoveReport(NetworkedItem item)
+ {
+
+ }
+
+ public void ClearReports()
+ {
+ foreach (var report in JobReports)
+ {
+ Destroy(report.gameObject);
+ }
+
+ JobReports.Clear();
+ }
+
+ protected void OnDisable()
+ {
+ if (UnloadWatcher.isQuitting || UnloadWatcher.isUnloading)
+ return;
+
+ // Remove from lookup caches
+ jobToNetworkedJob.Remove(Job);
+ jobIdToNetworkedJob.Remove(Job.ID);
+ jobIdToJob.Remove(Job.ID);
+
+ // Unsubscribe from events
+ Job.JobTaken -= OnJobTaken;
+ Job.JobAbandoned -= OnJobAbandoned;
+ Job.JobCompleted -= OnJobCompleted;
+ Job.JobExpired -= OnJobExpired;
+
+ Destroy(this);
+ }
+
+}
diff --git a/Multiplayer/Components/Networking/Jobs/NetworkedTask.cs b/Multiplayer/Components/Networking/Jobs/NetworkedTask.cs
new file mode 100644
index 00000000..a938b837
--- /dev/null
+++ b/Multiplayer/Components/Networking/Jobs/NetworkedTask.cs
@@ -0,0 +1,97 @@
+using DV.Logic.Job;
+using System;
+using System.Collections.Generic;
+
+namespace Multiplayer.Components.Networking.Jobs;
+
+public class NetworkedTask : IdMonoBehaviour
+{
+ #region Lookup Cache
+ private static readonly Dictionary taskToNetworkedTask = [];
+
+ public static bool TryGet(ushort netId, out NetworkedTask networkedTask)
+ {
+ bool b = Get(netId, out IdMonoBehaviour rawObj);
+ networkedTask = (NetworkedTask)rawObj;
+ return b;
+ }
+
+ public static bool TryGet(Task task, out NetworkedTask networkedTask)
+ {
+ return taskToNetworkedTask.TryGetValue(task, out networkedTask);
+ }
+
+ public static bool TryGet(ushort netId, out Task task)
+ {
+ task = null;
+
+ if (!Get(netId, out IdMonoBehaviour rawObj) || rawObj == null)
+ return false;
+
+ task = ((NetworkedTask)rawObj).Task;
+
+ return task != null;
+ }
+
+ public static bool TryGetNetId(Task task, out ushort netId)
+ {
+ if (taskToNetworkedTask.TryGetValue(task, out var networkedTask) && networkedTask != null)
+ {
+ netId = networkedTask.NetId;
+ return true;
+ }
+
+ netId = 0;
+ return false;
+ }
+ #endregion
+
+ protected override bool IsIdServerAuthoritative => true;
+
+ public Task Task { get; private set; }
+
+ private float lastStartTime;
+ private float lastFinishTime;
+ private TaskState lastState;
+
+ public void Initialize(Task task, ushort netId = 0)
+ {
+ if (task == null)
+ {
+ Multiplayer.LogError($"NetworkedTask.Initialize(): Task is null\r\n{Environment.StackTrace}");
+ return;
+ }
+
+ if (taskToNetworkedTask.ContainsKey(task))
+ {
+ Multiplayer.LogError($"NetworkedTask.Initialize(): Task {task.InstanceTaskType} for jobId {task.Job.ID} is already registered");
+ Destroy(this);
+ return;
+ }
+
+ Task = task;
+ taskToNetworkedTask[Task] = this;
+ if (netId != 0)
+ NetId = netId;
+ }
+
+ protected override void OnDestroy()
+ {
+ base.OnDestroy();
+
+ if (Task != null)
+ taskToNetworkedTask.Remove(Task);
+ }
+
+ public void SetState(TaskState newState)
+ {
+ if (lastState == newState && lastStartTime == Task.taskStartTime && lastFinishTime == Task.taskFinishTime)
+ return;
+
+ lastState = newState;
+ lastStartTime = Task.taskStartTime;
+ lastFinishTime = Task.taskFinishTime;
+
+ NetworkLifecycle.Instance.Server.SendTaskUpdate(NetId, newState, Task.taskStartTime, Task.taskFinishTime);
+ }
+}
diff --git a/Multiplayer/Components/Networking/Jobs/NetworkedWarehouseMachineController.cs b/Multiplayer/Components/Networking/Jobs/NetworkedWarehouseMachineController.cs
new file mode 100644
index 00000000..bb89aecf
--- /dev/null
+++ b/Multiplayer/Components/Networking/Jobs/NetworkedWarehouseMachineController.cs
@@ -0,0 +1,220 @@
+using DV.Logic.Job;
+using DV.ThingTypes;
+using DV.ThingTypes.TransitionHelpers;
+using Multiplayer.Components.Networking.Train;
+using Multiplayer.Networking.Data;
+using Multiplayer.Networking.Packets.Clientbound.Jobs;
+using System.Collections.Generic;
+using static WarehouseMachineController;
+
+
+
+namespace Multiplayer.Components.Networking.Jobs;
+
+public class NetworkedWarehouseMachineController : IdMonoBehaviour
+{
+ #region Lookup Cache
+ private static readonly Dictionary