diff --git a/.gitignore b/.gitignore
index 87860e1..d792194 100644
--- a/.gitignore
+++ b/.gitignore
@@ -306,3 +306,5 @@ MultiplayerAssets/ProjectSettings/*
!MultiplayerAssets/ProjectSettings/ProjectVersion.txt
# Packages
!MultiplayerAssets/Packages
+/Lobby Servers/Rust Server/target
+*.pem
diff --git a/Directory.Build.targets.EXAMPLE b/Directory.Build.targets.EXAMPLE
new file mode 100644
index 0000000..29314c4
--- /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 0000000..c8f0917
--- /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 0000000..ae751d4
--- /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 0000000..c649d39
--- /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 0000000..c6caf42
--- /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 0000000..5bc4c50
--- /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 0000000..52073ea
--- /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 0000000..68751f1
--- /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 0000000..f323dcb
--- /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 0000000..cd9d574
--- /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 0000000..f80e1d8
--- /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 0000000..2e80b78
--- /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 0000000..db84e87
--- /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 0000000..e863e8b
--- /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 0000000..bc25a1f
--- /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 0000000..36961fd
--- /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 0000000..286a442
--- /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 0000000..a4c9abc
--- /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 0000000..f8c9f70
--- /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 0000000..a1335a9
--- /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 0000000..b89c13c
--- /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/Components/IdMonoBehaviour.cs b/Multiplayer/Components/IdMonoBehaviour.cs
index f8fa3c6..44e05f8 100644
--- a/Multiplayer/Components/IdMonoBehaviour.cs
+++ b/Multiplayer/Components/IdMonoBehaviour.cs
@@ -9,7 +9,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 +32,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 0000000..3f35b8d
--- /dev/null
+++ b/Multiplayer/Components/MainMenu/HostGamePane.cs
@@ -0,0 +1,431 @@
+using System;
+using System.Reflection;
+using DV;
+using DV.UI;
+using DV.UI.PresetEditors;
+using DV.UIFramework;
+using DV.Localization;
+using DV.Common;
+using Multiplayer.Utils;
+using TMPro;
+using UnityEngine;
+using UnityEngine.UI;
+using UnityEngine.Events;
+using Multiplayer.Networking.Data;
+using Multiplayer.Components.Networking;
+using Multiplayer.Components.Util;
+using UnityModManagerNet;
+using System.Linq;
+using Multiplayer.Networking.Managers.Server;
+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;
+ Toggle gamePublic;
+ ButtonDV startButton;
+
+ public ISaveGame saveGame;
+ public UIStartGameData startGameData;
+ public AUserProfileProvider userProvider;
+ public AScenarioProvider scenarioProvider;
+ LauncherController lcInstance;
+
+ public Action continueCareerRequested;
+ #region setup
+
+ public void Awake()
+ {
+ Multiplayer.Log("HostGamePane Awake()");
+
+ CleanUI();
+ BuildUI();
+ ValidateInputs(null);
+ }
+
+ public void Start()
+ {
+ Multiplayer.Log("HostGamePane Started");
+
+ }
+
+ public void OnEnable()
+ {
+ //Multiplayer.Log("HostGamePane OnEnable()");
+ this.SetupListeners(true);
+ }
+
+ // 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 sliderPrefab = goMMC.FindChildByName("Field Of View").gameObject;
+ if (sliderPrefab == null)
+ {
+ Multiplayer.LogError("SliderLimitSession 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;
+
+ GameObject go = GameObject.Instantiate(inputPrefab, NewContentGroup(controls, scroller.viewport.sizeDelta).transform,false);
+ go.name = "Server Name";
+ //go.AddComponent();
+ 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();
+
+
+ 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();
+
+
+ go = GameObject.Instantiate(cbPrefab, NewContentGroup(controls, scroller.viewport.sizeDelta).transform, false);
+ go.name = "Public";
+ TMP_Text label = go.FindChildByName("text").GetComponent();
+ label.text = "Public Game";
+ gamePublic = go.GetComponent();
+ gamePublic.isOn = Multiplayer.Settings.PublicGame;
+ gamePublic.interactable = true;
+ go.GetComponentInChildren().key = Locale.SERVER_HOST_PUBLIC_KEY;
+ GameObject.Destroy(go.GetComponentInChildren());
+ go.ResetTooltip();
+
+
+ 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;
+
+
+ go = GameObject.Instantiate(dividerPrefab, NewContentGroup(controls, scroller.viewport.sizeDelta).transform, false);
+ go.name = "Divider";
+
+
+ go = GameObject.Instantiate(sliderPrefab, NewContentGroup(controls, scroller.viewport.sizeDelta).transform, false);
+ go.name = "Max Players";
+ go.FindChildByName("[text label]").GetComponent().key = Locale.SERVER_HOST_MAX_PLAYERS_KEY;
+ go.ResetTooltip();
+ go.FindChildByName("[text label]").GetComponent().UpdateLocalization();
+ maxPlayers = go.GetComponent();
+ maxPlayers.stepIncrement = 1;
+ maxPlayers.minValue = MIN_PLAYERS;
+ maxPlayers.maxValue = MAX_PLAYERS;
+ maxPlayers.value = Mathf.Clamp(Multiplayer.Settings.MaxPlayers,MIN_PLAYERS,MAX_PLAYERS);
+ maxPlayers.interactable = true;
+
+
+ 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 = "7777";
+ port.text = (Multiplayer.Settings.Port >= MIN_PORT && Multiplayer.Settings.Port <= MAX_PORT) ? Multiplayer.Settings.Port.ToString() : DEFAULT_PORT.ToString();
+
+
+ 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(StartClick);
+
+
+ }
+
+ 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 text)
+ {
+ bool valid = true;
+ int portNum;
+
+ if (serverName.text.Trim() == "" || serverName.text.Length > MAX_SERVER_NAME_LEN)
+ valid = false;
+
+ if (port.text != "")
+ {
+ portNum = int.Parse(port.text);
+ if(portNum < MIN_PORT || portNum > MAX_PORT)
+ return;
+
+ }
+
+ if( port.text == "" && (Multiplayer.Settings.Port < MIN_PORT || Multiplayer.Settings.Port > MAX_PORT))
+ valid = false;
+
+ startButton.ToggleInteractable(valid);
+
+ //Multiplayer.Log($"HostPane validated: {valid}");
+ }
+
+ private void StartClick()
+ {
+
+ 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.isPublic = gamePublic.isOn;
+
+ 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;
+
+ ModInfo[] serverMods = ModInfo.FromModEntries(UnityModManager.modEntries)
+ .Where(mod => !NetworkServer.modWhiteList.Contains(mod.Id) && mod.Id != Multiplayer.ModEntry.Info.Id).ToArray();
+
+ string requiredMods = "";
+ if (serverMods.Length > 0)
+ {
+ requiredMods = string.Join(", ", serverMods.Select(mod => $"{{{mod.Id}, {mod.Version}}}"));
+ }
+
+ serverData.RequiredMods = requiredMods; //FIX THIS - get the mods required
+ serverData.GameVersion = BuildInfo.BUILD_VERSION_MAJOR.ToString();
+ 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.PublicGame = serverData.isPublic;
+ 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 02a6d6b..2dea38f 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 be42068..0000000
--- 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 0000000..997e367
--- /dev/null
+++ b/Multiplayer/Components/MainMenu/ServerBrowser/IServerBrowserGameDetails.cs
@@ -0,0 +1,37 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+using System.Runtime.CompilerServices;
+using Newtonsoft.Json.Linq;
+using UnityEngine;
+using Newtonsoft.Json;
+
+namespace Multiplayer.Components.MainMenu
+{
+ //
+ 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; }
+ string RequiredMods { get; set; }
+ string GameVersion { get; set; }
+ string MultiplayerVersion { get; set; }
+ string ServerDetails { get; set; }
+ int Ping {get; set; }
+ bool isPublic { 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 0000000..170caab
--- /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/ServerBrowserDummyElement.cs b/Multiplayer/Components/MainMenu/ServerBrowser/ServerBrowserDummyElement.cs
new file mode 100644
index 0000000..bf56f5f
--- /dev/null
+++ b/Multiplayer/Components/MainMenu/ServerBrowser/ServerBrowserDummyElement.cs
@@ -0,0 +1,55 @@
+using DV.UI;
+using DV.UIFramework;
+using DV.Localization;
+using Multiplayer.Utils;
+using System.ComponentModel;
+using TMPro;
+using UnityEngine;
+using UnityEngine.UI;
+
+namespace Multiplayer.Components.MainMenu.ServerBrowser
+{
+ public class ServerBrowserDummyElement : AViewElement
+ {
+ private TextMeshProUGUI networkName;
+
+ protected override void Awake()
+ {
+ // Find and assign TextMeshProUGUI components for displaying server details
+ GameObject networkNameGO = this.FindChildByName("name [noloc]");
+ networkName = networkNameGO.GetComponent();
+ 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, AGridView _)
+ {
+ //do nothing
+ }
+
+ private void UpdateView(object sender = null, PropertyChangedEventArgs e = null)
+ {
+ //do nothing
+ }
+ }
+}
diff --git a/Multiplayer/Components/MainMenu/ServerBrowser/ServerBrowserElement.cs b/Multiplayer/Components/MainMenu/ServerBrowser/ServerBrowserElement.cs
new file mode 100644
index 0000000..f925ec6
--- /dev/null
+++ b/Multiplayer/Components/MainMenu/ServerBrowser/ServerBrowserElement.cs
@@ -0,0 +1,132 @@
+using DV.UIFramework;
+using Multiplayer.Utils;
+using System;
+using TMPro;
+using UnityEngine;
+using UnityEngine.UI;
+
+namespace Multiplayer.Components.MainMenu.ServerBrowser
+{
+ public class ServerBrowserElement : AViewElement
+ {
+ private TextMeshProUGUI networkName;
+ 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_POS_X = 650; // X position for the ping text
+
+ 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;
+
+ protected override void Awake()
+ {
+ // Find and assign TextMeshProUGUI components for displaying server details
+ networkName = 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();
+
+ // Fix alignment of the player count text relative to the network name text
+ Vector3 namePos = networkName.transform.position;
+ Vector2 nameSize = networkName.rectTransform.sizeDelta;
+ playerCount.transform.position = new Vector3(namePos.x + nameSize.x, namePos.y, namePos.z);
+
+ // Adjust the size and position of the ping text
+ Vector2 rowSize = transform.GetComponentInParent().sizeDelta;
+ Vector3 pingPos = ping.transform.position;
+ Vector2 pingSize = ping.rectTransform.sizeDelta;
+
+ ping.rectTransform.sizeDelta = new Vector2(PING_WIDTH, pingSize.y);
+ ping.transform.position = new Vector3(PING_POS_X, pingPos.y, pingPos.z);
+ 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 += (PING_POS_X - LANpos.x - LANSize.x) / 2;
+ goIconLAN.transform.localPosition = LANpos;
+ iconLAN = goIconLAN.GetComponent();
+ iconLAN.sprite = Multiplayer.AssetIndex.lanIcon;
+ }
+
+ }
+
+ public override void SetData(IServerBrowserGameDetails data, AGridView _)
+ {
+ // 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()
+ {
+
+ // Update the text fields with the data from the server
+ networkName.text = data.Name;
+ playerCount.text = $"{data.CurrentPlayers} / {data.MaxPlayers}";
+
+ //if (data.MultiplayerVersion == Multiplayer.Ver)
+ ping.text = $"{(data.Ping < 0 ? "?" : data.Ping)} ms";
+ //else
+ // ping.text = $"N/A";
+
+ // 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)
+ {
+ switch (ping)
+ {
+ case PING_THRESHOLD_NONE:
+ return PING_COLOR_UNKNOWN;
+ case < PING_THRESHOLD_EXCELLENT:
+ return PING_COLOR_EXCELLENT;
+ case < PING_THRESHOLD_GOOD:
+ return PING_COLOR_GOOD;
+ case < PING_THRESHOLD_HIGH:
+ return PING_COLOR_HIGH;
+ default:
+ return PING_COLOR_POOR;
+ }
+ }
+ }
+}
diff --git a/Multiplayer/Components/MainMenu/ServerBrowser/ServerBrowserGridView.cs b/Multiplayer/Components/MainMenu/ServerBrowser/ServerBrowserGridView.cs
new file mode 100644
index 0000000..ea52694
--- /dev/null
+++ b/Multiplayer/Components/MainMenu/ServerBrowser/ServerBrowserGridView.cs
@@ -0,0 +1,44 @@
+using System;
+using DV.UI;
+using DV.UIFramework;
+using Multiplayer.Components.MainMenu.ServerBrowser;
+using UnityEngine;
+using UnityEngine.UI;
+
+namespace Multiplayer.Components.MainMenu
+{
+ [RequireComponent(typeof(ContentSizeFitter))]
+ [RequireComponent(typeof(VerticalLayoutGroup))]
+ //
+ public class ServerBrowserGridView : AGridView
+ {
+
+ protected override void Awake()
+ {
+ //Multiplayer.Log("serverBrowserGridview Awake()");
+
+ //copy the copy
+ this.viewElementPrefab.SetActive(false);
+ this.dummyElementPrefab = Instantiate(this.viewElementPrefab);
+
+ //swap controllers
+ GameObject.Destroy(this.viewElementPrefab.GetComponent());
+ GameObject.Destroy(this.dummyElementPrefab.GetComponent());
+
+ this.viewElementPrefab.AddComponent();
+ this.dummyElementPrefab.AddComponent();
+
+ this.viewElementPrefab.name = "prefabServerBrowserElement";
+ this.dummyElementPrefab.name = "prefabServerBrowserDummyElement";
+
+ this.viewElementPrefab.SetActive(true);
+ this.dummyElementPrefab.SetActive(true);
+
+ }
+
+ public ServerBrowserElement GetElementAt(int index)
+ {
+ return transform.GetChild(index + indexOffset).GetComponent();
+ }
+ }
+}
diff --git a/Multiplayer/Components/MainMenu/ServerBrowserPane.cs b/Multiplayer/Components/MainMenu/ServerBrowserPane.cs
new file mode 100644
index 0000000..f3ab768
--- /dev/null
+++ b/Multiplayer/Components/MainMenu/ServerBrowserPane.cs
@@ -0,0 +1,1097 @@
+using System;
+using System.Collections;
+using DV.Localization;
+using DV.UI;
+using DV.UIFramework;
+using DV.Util;
+using DV.Utils;
+using Multiplayer.Components.Networking;
+using Multiplayer.Utils;
+using TMPro;
+using UnityEngine;
+using UnityEngine.UI;
+using System.Linq;
+using Multiplayer.Networking.Data;
+using DV;
+using System.Net;
+using LiteNetLib;
+using System.Collections.Generic;
+using Steamworks;
+using Steamworks.Data;
+
+namespace Multiplayer.Components.MainMenu
+{
+ public class ServerBrowserPane : MonoBehaviour
+ {
+ private enum ConnectionState
+ {
+ NotConnected,
+ AttemptingSteamRelay,
+ AttemptingIPv6,
+ AttemptingIPv6Punch,
+ AttemptingIPv4,
+ AttemptingIPv4Punch,
+ Failed,
+ Aborted
+ }
+
+ private const int MAX_PORT_LEN = 5;
+ private const int MIN_PORT = 1024;
+ private const int MAX_PORT = 49151;
+
+ //Gridview variables
+ private readonly ObservableCollectionExt gridViewModel = [];
+ private ServerBrowserGridView gridView;
+ private ScrollRect parentScroller;
+ private string serverIDOnRefresh;
+ 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;
+
+ //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;
+
+
+ #region setup
+
+ public void Awake()
+ {
+ Multiplayer.Log("MultiplayerPane Awake()");
+ joinedLobby?.Leave();
+ joinedLobby = null;
+
+ CleanUI();
+ BuildUI();
+
+ SetupServerBrowser();
+ RefreshGridView();
+ }
+
+ public void OnEnable()
+ {
+ //Multiplayer.Log("MultiplayerPane OnEnable()");
+ if (!this.parentScroller)
+ {
+ //Multiplayer.Log("Find ScrollRect");
+ this.parentScroller = this.gridView.GetComponentInParent();
+ //Multiplayer.Log("Found ScrollRect");
+ }
+ this.SetupListeners(true);
+ this.serverIDOnRefresh = "";
+
+ 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();
+ IndexChanged(gridView); //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 && lobbyToJoin?.Data?.Count() > 0)
+ {
+ //For invites
+ Multiplayer.Log($"Player Invite initiated");
+ if (lobbyToJoin != null)
+ {
+ direct = false;
+ selectedLobby = lobbyToJoin;
+ lobbyToJoin = null;
+
+ string hasPass = selectedLobby?.GetData(SteamworksUtils.LOBBY_HAS_PASSWORD);
+ Multiplayer.Log($"Player Invite ({selectedLobby?.Id}) Has Password: {hasPass}");
+
+ if (string.IsNullOrEmpty(hasPass))
+ {
+ Multiplayer.Log($"Player Invite ({selectedLobby?.Id}) Attempting connection...");
+ AttemptConnection();
+ }
+ else
+ {
+ Multiplayer.Log($"Player Invite ({selectedLobby?.Id}) Ask Password...");
+ ShowPasswordPopup();
+ }
+ }
+ }
+ }
+
+ 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));
+ content.transform.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;
+
+ // Create TextMeshProUGUI object
+ GameObject textContainerGO = new ("Details Container", typeof(HorizontalLayoutGroup));
+ textContainerGO.transform.SetParent(content.transform, false);
+ contentRT.localPosition = new Vector3(contentRT.localPosition.x + 10, contentRT.localPosition.y, contentRT.localPosition.z);
+
+
+ GameObject textGO = new("Details Text", typeof(TextMeshProUGUI));
+ textGO.transform.SetParent(textContainerGO.transform, false);
+ HorizontalLayoutGroup textHLG = textGO.GetComponent();
+ 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.";
+
+ // 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();
+ gridView = GridviewGO.AddComponent();
+
+ //grab the original prefab
+ slgv.viewElementPrefab.SetActive(false);
+ gridView.viewElementPrefab = Instantiate(slgv.viewElementPrefab);
+ slgv.viewElementPrefab.SetActive(true);
+
+ //Remove original controller
+ GameObject.Destroy(slgv);
+
+ //Don't forget to re-enable!
+ GridviewGO.SetActive(true);
+
+ gridView.showDummyElement = true;
+ }
+ private void SetupListeners(bool on)
+ {
+ if (on)
+ {
+ this.gridView.SelectedIndexChanged += this.IndexChanged;
+ }
+ else
+ {
+ this.gridView.SelectedIndexChanged -= this.IndexChanged;
+ }
+ }
+ #endregion
+
+ #region UI callbacks
+ private void RefreshAction()
+ {
+ if (serverRefreshing)
+ return;
+
+ if (selectedServer != null)
+ serverIDOnRefresh = selectedServer.id;
+
+ remoteServers.Clear();
+
+ serverRefreshing = true;
+ buttonJoin.ToggleInteractable(false);
+ buttonRefresh.ToggleInteractable(false);
+
+ if (DVSteamworks.Success)
+ ListActiveLobbies();
+
+ }
+ private void JoinAction()
+ {
+ if (selectedServer != null)
+ {
+ 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;
+ password = null; //clear the password
+
+ if (selectedServer.HasPassword)
+ {
+ ShowPasswordPopup();
+ return;
+ }
+
+ AttemptConnection();
+
+ }
+ }
+ }
+
+ private void DirectAction()
+ {
+ //Debug.Log($"DirectAction()");
+ buttonDirectIP.ToggleInteractable(false);
+ buttonJoin.ToggleInteractable(false) ;
+
+ //making a direct connection
+ direct = true;
+ password = null;
+
+ //ShowSteamID();
+ ShowIpPopup();
+ }
+
+ private void IndexChanged(AGridView gridView)
+ {
+ if (serverRefreshing)
+ return;
+
+ if (gridView.SelectedModelIndex >= 0)
+ {
+ selectedServer = gridViewModel[gridView.SelectedModelIndex];
+
+ UpdateDetailsPane();
+
+ //Check if we can connect to this server
+ Multiplayer.Log($"Server: \"{selectedServer.GameVersion}\" \"{selectedServer.MultiplayerVersion}\"");
+ Multiplayer.Log($"Client: \"{BuildInfo.BUILD_VERSION_MAJOR}\" \"{Multiplayer.Ver}\"");
+ Multiplayer.Log($"Result: \"{selectedServer.GameVersion == BuildInfo.BUILD_VERSION_MAJOR.ToString()}\" \"{selectedServer.MultiplayerVersion == Multiplayer.Ver}\"");
+
+ bool canConnect = selectedServer.GameVersion == BuildInfo.BUILD_VERSION_MAJOR.ToString() &&
+ selectedServer.MultiplayerVersion == Multiplayer.Ver;
+
+ buttonJoin.ToggleInteractable(canConnect);
+ }
+ else
+ {
+ buttonJoin.ToggleInteractable(false);
+ }
+ }
+
+ private void UpdateElement(IServerBrowserGameDetails element)
+ {
+ int index = gridViewModel.IndexOf(element);
+
+ if (index >= 0)
+ {
+ var viewElement = gridView.GetElementAt(index);
+ viewElement?.UpdateView();
+ }
+ }
+ #endregion
+
+ private void UpdateDetailsPane()
+ {
+ string details;
+
+ if (selectedServer != null)
+ {
+ serverName.text = selectedServer.Name;
+
+ //note: built-in localisations have a trailing colon e.g. 'Game mode:'
+
+ details = "" + LocalizationAPI.L("launcher/game_mode", []) + " " + LobbyServerData.GetGameModeFromInt(selectedServer.GameMode) + "
";
+ details += "" + LocalizationAPI.L("launcher/difficulty", []) + " " + LobbyServerData.GetDifficultyFromInt(selectedServer.Difficulty) + "
";
+ details += "" + LocalizationAPI.L("launcher/in_game_time_passed", []) + " " + selectedServer.TimePassed + "
";
+ details += "" + Locale.SERVER_BROWSER__PLAYERS + ": " + selectedServer.CurrentPlayers + '/' + selectedServer.MaxPlayers + "
";
+ details += "" + Locale.SERVER_BROWSER__PASSWORD_REQUIRED + ": " + (selectedServer.HasPassword ? Locale.SERVER_BROWSER__YES : Locale.SERVER_BROWSER__NO) + "
";
+ details += "" + Locale.SERVER_BROWSER__MODS_REQUIRED + ": " + (string.IsNullOrEmpty(selectedServer.RequiredMods) ? Locale.SERVER_BROWSER__NO : Locale.SERVER_BROWSER__YES) + "
";
+ details += "
";
+ details += "" + Locale.SERVER_BROWSER__GAME_VERSION + ": " + (selectedServer.GameVersion != BuildInfo.BUILD_VERSION_MAJOR.ToString() ? "" : "") + selectedServer.GameVersion + "
";
+ details += "" + Locale.SERVER_BROWSER__MOD_VERSION + ": " + (selectedServer.MultiplayerVersion != Multiplayer.Ver ? "" : "") + selectedServer.MultiplayerVersion + "
";
+ details += "
";
+ details += selectedServer.ServerDetails;
+
+ //Multiplayer.Log("Finished Prepping Data");
+ detailsPane.text = details;
+ }
+ }
+
+ 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);
+ IndexChanged(gridView); //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 ShowSteamID()
+ //{
+ // var popup = MainMenuThingsAndStuff.Instance.ShowRenamePopup();
+ // if (popup == null)
+ // {
+ // Multiplayer.LogError("Popup not found.");
+ // return;
+ // }
+
+ // popup.labelTMPro.text = "SteamID";
+ // //popup.GetComponentInChildren().text = Multiplayer.Settings.LastRemoteIP;
+
+ // popup.Closed += result =>
+ // {
+ // if (result.closedBy == PopupClosedByAction.Abortion)
+ // {
+ // buttonDirectIP.ToggleInteractable(true);
+ // IndexChanged(gridView); //re-enable the join button if a valid gridview item is selected
+ // return;
+ // }
+
+ // steamId = popup.GetComponentInChildren().text;
+ // Multiplayer.LogDebug(() => $"Attempting to connecto SteamID: {steamId}");
+
+ // ShowPasswordPopup();
+ // };
+ //}
+
+ 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)
+ {
+ buttonDirectIP.ToggleInteractable(true);
+ return;
+ }
+
+ if (direct)
+ {
+ //store params for later
+ Multiplayer.Settings.LastRemoteIP = address;
+ Multiplayer.Settings.LastRemotePort = portNumber;
+ Multiplayer.Settings.LastRemotePassword = result.data;
+ }
+
+ password = result.data;
+
+ AttemptConnection();
+ };
+ }
+
+ 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 async void AttemptConnection()
+ {
+
+ Multiplayer.Log($"AttemptConnection Direct: {direct}, Address: {address}, Lobby: {selectedLobby?.Id.ToString()}");
+
+ attempt = 0;
+ connectionState = ConnectionState.NotConnected;
+ ShowConnectingPopup();
+
+ if (!direct)
+ {
+ if(selectedLobby != null)
+ {
+ joinedLobby = selectedLobby; //store the lobby for when we disconnect
+
+ connectionState = ConnectionState.AttemptingSteamRelay;
+
+ var joinResult = await joinedLobby?.Join();
+ if (joinResult == RoomEnter.Success)
+ {
+ string hostId = ((Lobby)joinedLobby).Owner.Id.Value.ToString();
+ NetworkLifecycle.Instance.StartClient(hostId, -1, password, false, OnDisconnect);
+ }
+ else
+ {
+ Multiplayer.LogDebug(() => "AttemptConnection() Leaving Lobby");
+ joinedLobby?.Leave();
+ joinedLobby = null;
+ Multiplayer.Log($"Failed to join lobby: {joinResult}");
+ AttemptFail();
+ }
+
+ 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();
+ }
+
+ return;
+ }
+
+ 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
+ }
+
+ if (gameObject != null && gameObject.activeInHierarchy)
+ {
+ if (gridView != null)
+ IndexChanged(gridView);
+
+ if (buttonDirectIP != null && buttonDirectIP.gameObject != null)
+ buttonDirectIP.ToggleInteractable(true);
+ }
+ }
+
+ private void OnDisconnect(DisconnectReason reason, string message)
+ {
+ Multiplayer.Log($"Disconnected due to: {reason}, \"{message}\"");
+
+ string displayMessage = message;
+
+ Multiplayer.LogDebug(() => "OnDisconnect() Leaving Lobby");
+ joinedLobby?.Leave();
+ joinedLobby = null;
+
+ if (string.IsNullOrEmpty(message))
+ {
+ //fallback for no message (server initiated disconnects should have a message)
+ //if (reason == DisconnectReason.ConnectionFailed)
+ //{
+ // switch (connectionState)
+ // {
+ // case ConnectionState.AttemptingIPv6:
+ // if (Multiplayer.Settings.EnableNatPunch)
+ // AttemptIPv6Punch();
+ // else
+ // AttemptIPv4();
+ // return;
+ // case ConnectionState.AttemptingIPv6Punch:
+ // AttemptIPv4();
+ // return;
+ // case ConnectionState.AttemptingIPv4:
+ // if (Multiplayer.Settings.EnableNatPunch)
+ // {
+ // AttemptIPv4Punch();
+ // return;
+ // }
+ // break;
+ // }
+ //}
+
+ displayMessage = GetDisplayMessageForDisconnect(reason);
+ AttemptFail();
+ }
+ else
+ {
+ connectionState = ConnectionState.NotConnected;
+ }
+
+ NetworkLifecycle.Instance.QueueMainMenuEvent(() =>
+ {
+ Multiplayer.LogDebug(() => "OnDisconnect() Queuing");
+ MainMenuThingsAndStuff.Instance?.ShowOkPopup(displayMessage, () => { });
+ });
+ }
+
+ private string GetDisplayMessageForDisconnect(DisconnectReason reason)
+ {
+ return reason switch
+ {
+ DisconnectReason.UnknownHost => "Unknown Host",
+ DisconnectReason.DisconnectPeerCalled => "Player Kicked",
+ DisconnectReason.ConnectionFailed => "Host Unreachable",
+ DisconnectReason.ConnectionRejected => "Rejected!",
+ DisconnectReason.RemoteConnectionClose => "Server Shutting Down",
+ DisconnectReason.Timeout => "Server Timed Out",
+ _ => "Connection Failed"
+ };
+ }
+ #endregion
+
+
+ #region steam lobby
+ private async void ListActiveLobbies()
+ {
+ lobbies = await SteamMatchmaking.LobbyList.WithMaxResults(100)
+ //.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 gridViewModel)
+ {
+ 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()
+ {
+
+ var allServers = new List();
+ allServers.AddRange(remoteServers);
+
+ // Get all active IDs
+ List activeIDs = allServers.Select(s => s.id).Distinct().ToList();
+
+ // Find servers to remove
+ List removeList = gridViewModel.Where(gv => !activeIDs.Contains(gv.id)).ToList();
+
+ // Remove expired servers
+ foreach (var remove in removeList)
+ {
+ gridViewModel.Remove(remove);
+ }
+
+ // Update existing servers and add new ones
+ foreach (var server in allServers)
+ {
+ var existingServer = gridViewModel.FirstOrDefault(gv => gv.id == server.id);
+ if (existingServer != null)
+ {
+ // Update existing server
+ existingServer.TimePassed = server.TimePassed;
+ existingServer.CurrentPlayers = server.CurrentPlayers;
+ existingServer.LocalIPv4 = server.LocalIPv4;
+ existingServer.LastSeen = server.LastSeen;
+ }
+ else
+ {
+ // Add new server
+ gridViewModel.Add(server);
+ }
+ }
+
+ if (gridViewModel.Count() == 0)
+ {
+ gridView.showDummyElement = true;
+ buttonJoin.ToggleInteractable(false);
+ }
+ else
+ {
+ gridView.showDummyElement = false;
+ }
+
+ //Update the gridview rendering
+ gridView.SetModel(gridViewModel);
+
+ //if we have a server selected, we need to re-select it after refresh
+ if (serverIDOnRefresh != null)
+ {
+ int selID = Array.FindIndex(gridViewModel.ToArray(), server => server.id == serverIDOnRefresh);
+ if (selID >= 0)
+ {
+ gridView.SetSelected(selID);
+
+ if (this.parentScroller)
+ {
+ this.parentScroller.verticalNormalizedPosition = 1f - (float)selID / (float)gridView.Model.Count;
+ }
+ }
+ serverIDOnRefresh = null;
+ }
+ }
+
+ 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;
+ }
+ }
+}
diff --git a/Multiplayer/Components/Networking/Jobs/NetworkedJob.cs b/Multiplayer/Components/Networking/Jobs/NetworkedJob.cs
new file mode 100644
index 0000000..2f3232d
--- /dev/null
+++ b/Multiplayer/Components/Networking/Jobs/NetworkedJob.cs
@@ -0,0 +1,254 @@
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Linq;
+using DV.Logic.Job;
+using Multiplayer.Components.Networking.World;
+using Multiplayer.Networking.Data;
+using Newtonsoft.Json.Linq;
+using UnityEngine;
+
+
+namespace Multiplayer.Components.Networking.Jobs;
+
+public class NetworkedJob : IdMonoBehaviour
+{
+ #region Lookup Cache
+
+ private static readonly Dictionary jobToNetworkedJob = new();
+ private static readonly Dictionary jobIdToNetworkedJob = new();
+ private static readonly Dictionary jobIdToJob = new();
+
+ public static bool Get(ushort netId, out NetworkedJob obj)
+ {
+ bool b = Get(netId, out IdMonoBehaviour rawObj);
+ obj = (NetworkedJob)rawObj;
+ return b;
+ }
+
+ public static bool GetJob(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);
+ }
+
+ #endregion
+ 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 List JobReports = new List();
+
+ 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 = [];
+
+ protected override void Awake()
+ {
+ base.Awake();
+ }
+
+ private 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;
+
+ // 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();
+ }
+ }
+
+ private void AddToCache()
+ {
+ jobToNetworkedJob[Job] = this;
+ jobIdToNetworkedJob[Job.ID] = this;
+ jobIdToJob[Job.ID] = Job;
+ //Multiplayer.Log($"NetworkedJob added to cache: {Job.ID}");
+ }
+
+ 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();
+ }
+
+ private 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/NetworkLifecycle.cs b/Multiplayer/Components/Networking/NetworkLifecycle.cs
index 7c14288..d3917a9 100644
--- a/Multiplayer/Components/Networking/NetworkLifecycle.cs
+++ b/Multiplayer/Components/Networking/NetworkLifecycle.cs
@@ -1,13 +1,21 @@
using System;
using System.Collections;
using System.Collections.Generic;
+using System.Net;
using System.Text;
using DV.Scenarios.Common;
using DV.Utils;
using LiteNetLib;
using LiteNetLib.Utils;
-using Multiplayer.Networking.Listeners;
+using Multiplayer.Components.Networking.UI;
+using Multiplayer.Networking.Data;
+using Multiplayer.Networking.Managers;
+using Multiplayer.Networking.Managers.Client;
+using Multiplayer.Networking.Managers.Server;
+using Multiplayer.Networking.TransportLayers;
using Multiplayer.Utils;
+using Newtonsoft.Json;
+using Steamworks;
using UnityEngine;
using UnityEngine.SceneManagement;
@@ -19,6 +27,11 @@ public class NetworkLifecycle : SingletonBehaviour
public const byte TICK_RATE = 24;
private const float TICK_INTERVAL = 1.0f / TICK_RATE;
+ public LobbyServerData serverData;
+ public bool IsPublicGame { get; set; } = false;
+ public bool IsSinglePlayer { get; set; } = true;
+
+
public NetworkServer Server { get; private set; }
public NetworkClient Client { get; private set; }
@@ -28,7 +41,7 @@ public class NetworkLifecycle : SingletonBehaviour
public bool IsServerRunning => Server?.IsRunning ?? false;
public bool IsClientRunning => Client?.IsRunning ?? false;
- public bool IsProcessingPacket => Client.IsProcessingPacket;
+ public bool IsProcessingPacket => Client?.IsProcessingPacket ?? false;
private PlayerListGUI playerList;
private NetworkStatsGui Stats;
@@ -36,12 +49,12 @@ public class NetworkLifecycle : SingletonBehaviour
private readonly ExecutionTimer tickWatchdog = new(0.25f);
///
- /// Whether the provided NetPeer is the host.
+ /// Whether the provided ITransportPeer is the host.
/// Note that this does NOT check authority, and should only be used for client-only logic.
///
- public bool IsHost(NetPeer peer)
+ public bool IsHost(ITransportPeer peer)
{
- return Server?.IsRunning == true && Client?.IsRunning == true && Client?.selfPeer?.Id == peer?.Id;
+ return Server?.IsRunning == true && Client?.IsRunning == true && Client?.SelfPeer?.Id == peer?.Id;
}
///
@@ -50,7 +63,7 @@ public bool IsHost(NetPeer peer)
///
public bool IsHost()
{
- return IsHost(Client?.selfPeer);
+ return IsHost(Client?.SelfPeer);
}
private readonly Queue mainMenuLoadedQueue = new();
@@ -60,30 +73,20 @@ protected override void Awake()
base.Awake();
playerList = gameObject.AddComponent();
Stats = gameObject.AddComponent();
- RegisterPackets();
+ //RegisterPackets();
WorldStreamingInit.LoadingFinished += () => { playerList.RegisterListeners(); };
Settings.OnSettingsUpdated += OnSettingsUpdated;
SceneManager.sceneLoaded += (scene, _) =>
{
if (scene.buildIndex != (int)DVScenes.MainMenu)
return;
+
+ playerList.UnRegisterListeners();
TriggerMainMenuEventLater();
};
StartCoroutine(PollEvents());
}
- private static void RegisterPackets()
- {
- IReadOnlyDictionary packetMappings = NetPacketProcessor.RegisterPacketTypes();
- Multiplayer.LogDebug(() =>
- {
- StringBuilder stringBuilder = new($"Registered {packetMappings.Count} packets. Mappings:\n");
- foreach (KeyValuePair kvp in packetMappings)
- stringBuilder.AppendLine($"{kvp.Value}: {kvp.Key}");
- return stringBuilder;
- });
- }
-
private void OnSettingsUpdated(Settings settings)
{
if (!IsClientRunning && !IsServerRunning)
@@ -111,25 +114,42 @@ public void QueueMainMenuEvent(Action action)
mainMenuLoadedQueue.Enqueue(action);
}
- public bool StartServer(int port, IDifficulty difficulty)
+ public bool StartServer(IDifficulty difficulty)
{
+ int port = Multiplayer.Settings.Port;
+
if (Server != null)
throw new InvalidOperationException("NetworkManager already exists!");
+
+ if (!IsSinglePlayer)
+ {
+ if (serverData != null)
+ {
+ port = serverData.port;
+ }
+ }
+
Multiplayer.Log($"Starting server on port {port}");
- NetworkServer server = new(difficulty, Multiplayer.Settings);
+ NetworkServer server = new(difficulty, Multiplayer.Settings, IsSinglePlayer, serverData);
+
+ //reset for next game
+ IsSinglePlayer = true;
+ serverData = null;
+
if (!server.Start(port))
return false;
+
Server = server;
- StartClient("localhost", port, Multiplayer.Settings.Password);
+ StartClient(IPAddress.Loopback.ToString(), port, Multiplayer.Settings.Password, IsSinglePlayer, null/* (DisconnectReason dr,string msg) =>{ }*/);
return true;
}
- public void StartClient(string address, int port, string password)
+ public void StartClient(string address, int port, string password, bool isSinglePlayer, Action onDisconnect )
{
if (Client != null)
throw new InvalidOperationException("NetworkManager already exists!");
NetworkClient client = new(Multiplayer.Settings);
- client.Start(address, port, password);
+ client.Start(address, port, password, isSinglePlayer, onDisconnect);
Client = client;
OnSettingsUpdated(Multiplayer.Settings); // Show stats if enabled
}
@@ -156,8 +176,11 @@ private IEnumerator PollEvents()
tickWatchdog.Stop(time => Multiplayer.LogWarning($"OnTick took {time} ms!"));
}
- TickManager(Client);
- TickManager(Server);
+ if(Client != null)
+ TickManager(Client);
+
+ if(Server != null)
+ TickManager(Server);
float elapsedTime = tickTimer.Stop();
float remainingTime = Mathf.Max(0f, TICK_INTERVAL - elapsedTime);
@@ -186,7 +209,7 @@ private void TickManager(NetworkManager manager)
public void Stop()
{
- if (Stats != null) Stats.Hide();
+ Stats?.Hide();
Server?.Stop();
Client?.Stop();
Server = null;
@@ -206,4 +229,5 @@ public static void CreateLifecycle()
gameObject.AddComponent();
DontDestroyOnLoad(gameObject);
}
+
}
diff --git a/Multiplayer/Components/Networking/Player/NetworkedPlayer.cs b/Multiplayer/Components/Networking/Player/NetworkedPlayer.cs
index fec0ea6..561267a 100644
--- a/Multiplayer/Components/Networking/Player/NetworkedPlayer.cs
+++ b/Multiplayer/Components/Networking/Player/NetworkedPlayer.cs
@@ -10,7 +10,7 @@ public class NetworkedPlayer : MonoBehaviour
private const float LERP_SPEED = 5.0f;
public byte Id;
- public Guid Guid;
+ //public Guid Guid;
private AnimationHandler animationHandler;
private NameTag nameTag;
@@ -106,6 +106,14 @@ public void UpdatePosition(Vector3 position, Vector2 moveDir, float rotation, bo
public void UpdateCar(ushort netId)
{
isOnCar = NetworkedTrainCar.GetTrainCar(netId, out TrainCar trainCar);
+
+ if(isOnCar && trainCar == null)
+ {
+ //we have a desync!
+ Multiplayer.LogWarning($"Desync detected! Trying to update player '{username}' position to TrainCar netId {netId}, but car is null!");
+ return;
+ }
+
selfTransform.SetParent(isOnCar ? trainCar.transform : null, true);
targetPos = isOnCar ? transform.localPosition : selfTransform.position;
targetRotation = isOnCar ? transform.localRotation : selfTransform.rotation;
diff --git a/Multiplayer/Components/Networking/Player/NetworkedWorldMap.cs b/Multiplayer/Components/Networking/Player/NetworkedWorldMap.cs
index ccc044b..fe99948 100644
--- a/Multiplayer/Components/Networking/Player/NetworkedWorldMap.cs
+++ b/Multiplayer/Components/Networking/Player/NetworkedWorldMap.cs
@@ -1,25 +1,24 @@
+using DV;
using System.Collections.Generic;
using TMPro;
using UnityEngine;
namespace Multiplayer.Components.Networking.Player;
-public class NetworkedWorldMap : MonoBehaviour
+public class NetworkedMapMarkersController : MonoBehaviour
{
- private WorldMap worldMap;
private MapMarkersController markersController;
private GameObject textPrefab;
- private readonly Dictionary playerIndicators = new();
+ private readonly Dictionary playerIndicators = [];
private void Awake()
{
- worldMap = GetComponent();
markersController = GetComponent();
- textPrefab = worldMap.GetComponentInChildren().gameObject;
- foreach (NetworkedPlayer networkedPlayer in NetworkLifecycle.Instance.Client.PlayerManager.Players)
+ textPrefab = markersController.GetComponentInChildren().gameObject;
+ foreach (NetworkedPlayer networkedPlayer in NetworkLifecycle.Instance.Client.ClientPlayerManager.Players)
OnPlayerConnected(networkedPlayer.Id, networkedPlayer);
- NetworkLifecycle.Instance.Client.PlayerManager.OnPlayerConnected += OnPlayerConnected;
- NetworkLifecycle.Instance.Client.PlayerManager.OnPlayerDisconnected += OnPlayerDisconnected;
+ NetworkLifecycle.Instance.Client.ClientPlayerManager.OnPlayerConnected += OnPlayerConnected;
+ NetworkLifecycle.Instance.Client.ClientPlayerManager.OnPlayerDisconnected += OnPlayerDisconnected;
NetworkLifecycle.Instance.OnTick += OnTick;
}
@@ -30,22 +29,22 @@ private void OnDestroy()
NetworkLifecycle.Instance.OnTick -= OnTick;
if (UnloadWatcher.isUnloading)
return;
- NetworkLifecycle.Instance.Client.PlayerManager.OnPlayerConnected -= OnPlayerConnected;
- NetworkLifecycle.Instance.Client.PlayerManager.OnPlayerDisconnected -= OnPlayerDisconnected;
+ NetworkLifecycle.Instance.Client.ClientPlayerManager.OnPlayerConnected -= OnPlayerConnected;
+ NetworkLifecycle.Instance.Client.ClientPlayerManager.OnPlayerDisconnected -= OnPlayerDisconnected;
}
private void OnPlayerConnected(byte id, NetworkedPlayer player)
{
- Transform root = new GameObject($"{player.Username}'s Indicator") {
+ Transform root = new GameObject($"MapMarkerPlayer({player.Username})") {
transform = {
- parent = worldMap.playerIndicator.parent,
+ parent = this.transform,
localPosition = Vector3.zero,
localEulerAngles = Vector3.zero
}
}.transform;
WorldMapIndicatorRefs refs = root.gameObject.AddComponent();
- GameObject indicator = Instantiate(worldMap.playerIndicator.gameObject, root);
+ GameObject indicator = Instantiate(markersController.playerMarkerPrefab.gameObject, root);
indicator.transform.localPosition = Vector3.zero;
refs.indicator = indicator.transform;
@@ -54,6 +53,8 @@ private void OnPlayerConnected(byte id, NetworkedPlayer player)
textGo.transform.localEulerAngles = new Vector3(90f, 0, 0);
refs.text = textGo.GetComponent();
TMP_Text text = textGo.GetComponent();
+
+ text.name = "Player Name";
text.text = player.Username;
text.alignment = TextAlignmentOptions.Center;
text.fontSize /= 1.25f;
@@ -74,29 +75,47 @@ private void OnPlayerDisconnected(byte id, NetworkedPlayer player)
private void OnTick(uint obj)
{
- if (!worldMap.initialized)
+ if (markersController == null || UnloadWatcher.isUnloading)
return;
UpdatePlayers();
}
public void UpdatePlayers()
{
+ if (playerIndicators == null)
+ {
+ Multiplayer.LogDebug(() => $"NetworkedWorldMap.UpdatePlayers() playerIndicators: {playerIndicators != null}, count: {playerIndicators?.Count}");
+ return;
+ }
+
foreach (KeyValuePair kvp in playerIndicators)
{
- if (!NetworkLifecycle.Instance.Client.PlayerManager.TryGetPlayer(kvp.Key, out NetworkedPlayer networkedPlayer))
+ if(kvp.Value == null)
+ Multiplayer.LogDebug(() => $"NetworkedWorldMap.UpdatePlayers() key: {kvp.Key}, value is null: {kvp.Value == null}");
+
+ if (!NetworkLifecycle.Instance.Client.ClientPlayerManager.TryGetPlayer(kvp.Key, out NetworkedPlayer networkedPlayer))
{
Multiplayer.LogWarning($"Player indicator for {kvp.Key} exists but {nameof(NetworkedPlayer)} does not!");
OnPlayerDisconnected(kvp.Key, null);
continue;
}
+ if(kvp.Value == null)
+ {
+ Multiplayer.LogWarning($"NetworkedWorldMap.UpdatePlayers() key: {kvp.Key}, value is null skipping");
+ continue;
+ }
+
WorldMapIndicatorRefs refs = kvp.Value;
- bool active = worldMap.gameParams.PlayerMarkerDisplayed;
+ bool active = Globals.G.gameParams.PlayerMarkerDisplayed;
if (refs.gameObject.activeSelf != active)
refs.gameObject.SetActive(active);
if (!active)
+ {
+ Multiplayer.LogDebug(() => $"NetworkedWorldMap.UpdatePlayers() key: {kvp.Key}, is NOT active");
return;
+ }
Transform playerTransform = networkedPlayer.transform;
@@ -104,7 +123,7 @@ public void UpdatePlayers()
if (normalized != Vector3.zero)
refs.indicator.localRotation = Quaternion.LookRotation(normalized);
- Vector3 position = markersController.GetMapPosition(playerTransform.position - WorldMover.currentMove, worldMap.triggerExtentsXZ);
+ Vector3 position = markersController.GetMapPosition(playerTransform.position - WorldMover.currentMove, true);
refs.indicator.localPosition = position;
refs.text.localPosition = position with { y = position.y + 0.025f };
}
diff --git a/Multiplayer/Components/Networking/TickedQueue.cs b/Multiplayer/Components/Networking/TickedQueue.cs
index 31447e1..30aad3a 100644
--- a/Multiplayer/Components/Networking/TickedQueue.cs
+++ b/Multiplayer/Components/Networking/TickedQueue.cs
@@ -32,7 +32,7 @@ public void ReceiveSnapshot(T snapshot, uint tick)
private void OnTick(uint tick)
{
- if (snapshots.Count == 0)
+ if (snapshots.Count == 0 || UnloadWatcher.isUnloading)
return;
while (snapshots.Count > 0)
{
@@ -41,5 +41,10 @@ private void OnTick(uint tick)
}
}
+ public void Clear()
+ {
+ snapshots.Clear();
+ }
+
protected abstract void Process(T snapshot, uint snapshotTick);
}
diff --git a/Multiplayer/Components/Networking/Train/NetworkTrainsetWatcher.cs b/Multiplayer/Components/Networking/Train/NetworkTrainsetWatcher.cs
index 249b47f..d5d6752 100644
--- a/Multiplayer/Components/Networking/Train/NetworkTrainsetWatcher.cs
+++ b/Multiplayer/Components/Networking/Train/NetworkTrainsetWatcher.cs
@@ -1,9 +1,10 @@
using System.Linq;
using DV.Utils;
+using UnityEngine;
using JetBrains.Annotations;
-using Multiplayer.Networking.Data;
using Multiplayer.Networking.Packets.Clientbound.Train;
using Multiplayer.Utils;
+using Multiplayer.Networking.Data.Train;
namespace Multiplayer.Components.Networking.Train;
@@ -11,6 +12,9 @@ public class NetworkTrainsetWatcher : SingletonBehaviour
{
private ClientboundTrainsetPhysicsPacket cachedSendPacket;
+ const float DESIRED_FULL_SYNC_INTERVAL = 2f; // in seconds
+ const int MAX_UNSYNC_TICKS = (int)(NetworkLifecycle.TICK_RATE * DESIRED_FULL_SYNC_INTERVAL);
+
protected override void Awake()
{
base.Awake();
@@ -33,64 +37,102 @@ protected override void OnDestroy()
private void Server_OnTick(uint tick)
{
+ if (UnloadWatcher.isUnloading)
+ return;
+
cachedSendPacket.Tick = tick;
foreach (Trainset set in Trainset.allSets)
- Server_TickSet(set);
+ Server_TickSet(set, tick);
}
-
- private void Server_TickSet(Trainset set)
+ private void Server_TickSet(Trainset set, uint tick)
{
- bool dirty = false;
- foreach (TrainCar trainCar in set.cars)
+ bool anyCarMoving = false;
+ bool maxTicksReached = false;
+ bool anyTracksDirty = false;
+
+ if (set == null)
{
- if (trainCar.isStationary)
- continue;
- dirty = true;
- break;
+ Multiplayer.LogError($"Server_TickSet(): Received null set!");
+ return;
}
- if (!dirty)
+ cachedSendPacket.FirstNetId = set.firstCar.GetNetId();
+ cachedSendPacket.LastNetId = set.lastCar.GetNetId();
+ //car may not be initialised, missing a valid NetID
+ if (cachedSendPacket.FirstNetId == 0 || cachedSendPacket.LastNetId == 0)
return;
- cachedSendPacket.NetId = set.firstCar.GetNetId();
-
- if (set.cars.Contains(null))
+ foreach (TrainCar trainCar in set.cars)
{
- Multiplayer.LogError($"Trainset {set.id} ({set.firstCar.GetNetId()} has a null car!");
- return;
+ if (trainCar == null || !trainCar.gameObject.activeSelf)
+ {
+ Multiplayer.LogError($"Trainset {set.id} ({set.firstCar?.GetNetId()} has a null or inactive ({trainCar?.gameObject.activeSelf}) car!");
+ return;
+ }
+
+ //If we can locate the networked car, we'll add to the ticks counter and check if any tracks are dirty
+ if (NetworkedTrainCar.TryGetFromTrainCar(trainCar, out NetworkedTrainCar netTC))
+ {
+ maxTicksReached |= netTC.TicksSinceSync >= MAX_UNSYNC_TICKS;
+ anyTracksDirty |= netTC.BogieTracksDirty;
+ }
+
+ //Even if the car is stationary, if the max ticks has been exceeded we will still sync
+ if (!trainCar.isStationary)
+ anyCarMoving = true;
+
+ //we can finish checking early if we have BOTH a dirty and a max ticks
+ if (anyCarMoving && maxTicksReached)
+ break;
}
- if (set.cars.Any(car => !car.gameObject.activeSelf))
- {
- Multiplayer.LogError($"Trainset {set.id} ({set.firstCar.GetNetId()} has a non-active car!");
+ //if any car is dirty or exceeded its max ticks we will re-sync the entire train
+ if (!anyCarMoving && !maxTicksReached)
return;
- }
TrainsetMovementPart[] trainsetParts = new TrainsetMovementPart[set.cars.Count];
- bool anyTracksDirty = false;
+
for (int i = 0; i < set.cars.Count; i++)
{
TrainCar trainCar = set.cars[i];
- if (!trainCar.TryNetworked(out NetworkedTrainCar _))
+ if (!trainCar.TryNetworked(out NetworkedTrainCar networkedTrainCar))
{
Multiplayer.LogDebug(() => $"TrainCar {trainCar.ID} is not networked! Is active? {trainCar.gameObject.activeInHierarchy}");
continue;
}
- NetworkedTrainCar networkedTrainCar = trainCar.Networked();
- anyTracksDirty |= networkedTrainCar.BogieTracksDirty;
-
if (trainCar.derailed)
- trainsetParts[i] = new TrainsetMovementPart(RigidbodySnapshot.From(trainCar.rb));
+ {
+ trainsetParts[i] = new TrainsetMovementPart(networkedTrainCar.NetId, RigidbodySnapshot.From(trainCar.rb));
+ }
else
+ {
+ Vector3? position = null;
+ Quaternion? rotation = null;
+
+ //Have we exceeded the max ticks?
+ if (maxTicksReached)
+ {
+ //Multiplayer.Log($"Max Ticks Reached for TrainSet with cars {set.firstCar.ID}, {set.lastCar.ID}");
+
+ position = trainCar.transform.position - WorldMover.currentMove;
+ rotation = trainCar.transform.rotation;
+ networkedTrainCar.TicksSinceSync = 0; //reset this car's tick count
+ }
+
trainsetParts[i] = new TrainsetMovementPart(
+ networkedTrainCar.NetId,
trainCar.GetForwardSpeed(),
trainCar.stress.slowBuildUpStress,
- BogieData.FromBogie(trainCar.Bogies[0], networkedTrainCar.BogieTracksDirty, networkedTrainCar.Bogie1TrackDirection),
- BogieData.FromBogie(trainCar.Bogies[1], networkedTrainCar.BogieTracksDirty, networkedTrainCar.Bogie2TrackDirection)
+ BogieData.FromBogie(trainCar.Bogies[0], networkedTrainCar.BogieTracksDirty),
+ BogieData.FromBogie(trainCar.Bogies[1], networkedTrainCar.BogieTracksDirty),
+ position, //only used in full sync
+ rotation //only used in full sync
);
+ }
}
+ //Multiplayer.Log($"Server_TickSet({set.firstCar.ID}): SendTrainsetPhysicsUpdate, tick: {cachedSendPacket.Tick}");
cachedSendPacket.TrainsetParts = trainsetParts;
NetworkLifecycle.Instance.Server.SendTrainsetPhysicsUpdate(cachedSendPacket, anyTracksDirty);
}
@@ -101,24 +143,42 @@ private void Server_TickSet(Trainset set)
public void Client_HandleTrainsetPhysicsUpdate(ClientboundTrainsetPhysicsPacket packet)
{
- Trainset set = Trainset.allSets.Find(set => set.firstCar.GetNetId() == packet.NetId || set.lastCar.GetNetId() == packet.NetId);
+ Trainset set = Trainset.allSets.Find(set => set.firstCar.GetNetId() == packet.FirstNetId || set.lastCar.GetNetId() == packet.FirstNetId ||
+ set.firstCar.GetNetId() == packet.LastNetId || set.lastCar.GetNetId() == packet.LastNetId);
+
if (set == null)
{
- Multiplayer.LogDebug(() => $"Received {nameof(ClientboundTrainsetPhysicsPacket)} for unknown trainset with netId {packet.NetId}");
+ Multiplayer.LogWarning($"Received {nameof(ClientboundTrainsetPhysicsPacket)} for unknown trainset with FirstNetId: {packet.FirstNetId} and LastNetId: {packet.LastNetId}");
return;
}
if (set.cars.Count != packet.TrainsetParts.Length)
{
- Multiplayer.LogDebug(() =>
- $"Received {nameof(ClientboundTrainsetPhysicsPacket)} for trainset with netId {packet.NetId} with {packet.TrainsetParts.Length} parts, but trainset has {set.cars.Count} parts");
+ //log the discrepancies
+ Multiplayer.LogWarning(
+ $"Received {nameof(ClientboundTrainsetPhysicsPacket)} for trainset with FirstNetId: {packet.FirstNetId} and LastNetId: {packet.LastNetId} with {packet.TrainsetParts.Length} parts, but trainset has {set.cars.Count} parts");
+
+ for (int i = 0; i < packet.TrainsetParts.Length; i++)
+ {
+ if (NetworkedTrainCar.Get(packet.TrainsetParts[i].NetId ,out NetworkedTrainCar networkedTrainCar))
+ networkedTrainCar.Client_ReceiveTrainPhysicsUpdate(in packet.TrainsetParts[i], packet.Tick);
+ }
return;
}
+ //Check direction of trainset vs packet
+ if(set.firstCar.GetNetId() == packet.LastNetId)
+ packet.TrainsetParts = packet.TrainsetParts.Reverse().ToArray();
+
+ //Multiplayer.Log($"Client_HandleTrainsetPhysicsUpdate({set.firstCar.ID}):, tick: {packet.Tick}");
+
for (int i = 0; i < packet.TrainsetParts.Length; i++)
- set.cars[i].Networked().Client_ReceiveTrainPhysicsUpdate(in packet.TrainsetParts[i], packet.Tick);
+ {
+ if(set.cars[i].TryNetworked(out NetworkedTrainCar networkedTrainCar))
+ networkedTrainCar.Client_ReceiveTrainPhysicsUpdate(in packet.TrainsetParts[i], packet.Tick);
+ }
}
-
+
#endregion
[UsedImplicitly]
diff --git a/Multiplayer/Components/Networking/Train/NetworkedBogie.cs b/Multiplayer/Components/Networking/Train/NetworkedBogie.cs
index 6da72fd..c316948 100644
--- a/Multiplayer/Components/Networking/Train/NetworkedBogie.cs
+++ b/Multiplayer/Components/Networking/Train/NetworkedBogie.cs
@@ -1,23 +1,40 @@
using Multiplayer.Components.Networking.World;
-using Multiplayer.Networking.Data;
+using Multiplayer.Networking.Data.Train;
+using System.Collections;
using UnityEngine;
namespace Multiplayer.Components.Networking.Train;
public class NetworkedBogie : TickedQueue
{
+ private const int MAX_FRAMES = 60;
private Bogie bogie;
protected override void OnEnable()
{
- bogie = GetComponent();
- if (bogie == null)
+ StartCoroutine(WaitForBogie());
+ }
+
+ protected IEnumerator WaitForBogie()
+ {
+ int counter = 0;
+
+ while (bogie == null && counter < MAX_FRAMES)
{
- Multiplayer.LogError($"{gameObject.name}: {nameof(NetworkedBogie)} requires a {nameof(Bogie)} component on the same GameObject!");
- return;
+ bogie = GetComponent();
+ if (bogie == null)
+ {
+ counter++;
+ yield return new WaitForEndOfFrame();
+ }
}
base.OnEnable();
+
+ if (bogie == null)
+ {
+ Multiplayer.LogError($"{gameObject.name} ({bogie?.Car?.ID}): {nameof(NetworkedBogie)} requires a {nameof(Bogie)} component on the same GameObject! Waited {counter} iterations");
+ }
}
protected override void Process(BogieData snapshot, uint snapshotTick)
@@ -25,7 +42,7 @@ protected override void Process(BogieData snapshot, uint snapshotTick)
if (bogie.HasDerailed)
return;
- if (snapshot.HasDerailed || !bogie.track)
+ if (snapshot.HasDerailed)
{
bogie.Derail();
return;
@@ -33,12 +50,21 @@ protected override void Process(BogieData snapshot, uint snapshotTick)
if (snapshot.IncludesTrackData)
{
- if (NetworkedRailTrack.Get(snapshot.TrackNetId, out NetworkedRailTrack track))
- bogie.SetTrack(track.RailTrack, snapshot.PositionAlongTrack, snapshot.TrackDirection);
+ if (!NetworkedRailTrack.Get(snapshot.TrackNetId, out NetworkedRailTrack track))
+ {
+ Multiplayer.LogWarning($"NetworkedBogie.Process() Failed to find track {snapshot.TrackNetId} for bogie: {bogie.Car.ID}");
+ return;
+ }
+
+ bogie.SetTrack(track.RailTrack, snapshot.PositionAlongTrack, snapshot.TrackDirection);
+
}
else
{
- bogie.traveller.MoveToSpan(snapshot.PositionAlongTrack);
+ if(bogie.track)
+ bogie.traveller.MoveToSpan(snapshot.PositionAlongTrack);
+ else
+ Multiplayer.LogWarning($"NetworkedBogie.Process() No track for current bogie for bogie: {bogie?.Car?.ID}, unable to move position!");
}
int physicsSteps = Mathf.FloorToInt((NetworkLifecycle.Instance.Tick - (float)snapshotTick) / NetworkLifecycle.TICK_RATE / Time.fixedDeltaTime) + 1;
diff --git a/Multiplayer/Components/Networking/Train/NetworkedCarSpawner.cs b/Multiplayer/Components/Networking/Train/NetworkedCarSpawner.cs
index 4268ceb..4625a3d 100644
--- a/Multiplayer/Components/Networking/Train/NetworkedCarSpawner.cs
+++ b/Multiplayer/Components/Networking/Train/NetworkedCarSpawner.cs
@@ -1,7 +1,9 @@
-using System.Collections.Generic;
+using System.Collections;
+using DV.LocoRestoration;
+using DV.Simulation.Brake;
using DV.ThingTypes;
using Multiplayer.Components.Networking.World;
-using Multiplayer.Networking.Data;
+using Multiplayer.Networking.Data.Train;
using Multiplayer.Utils;
using UnityEngine;
@@ -9,16 +11,28 @@ namespace Multiplayer.Components.Networking.Train;
public static class NetworkedCarSpawner
{
- public static void SpawnCars(TrainsetSpawnPart[] parts)
+ public static void SpawnCars(TrainsetSpawnPart[] parts, bool autoCouple)
{
- TrainCar[] cars = new TrainCar[parts.Length];
+ NetworkedTrainCar[] cars = new NetworkedTrainCar[parts.Length];
+
+ //spawn the cars
for (int i = 0; i < parts.Length; i++)
cars[i] = SpawnCar(parts[i], true);
+
+ //Set brake params
+ for (int i = 0; i < cars.Length; i++)
+ SetBrakeParams(parts[i].BrakeData, cars[i].TrainCar);
+
+ //couple them if marked as coupled
for (int i = 0; i < cars.Length; i++)
- AutoCouple(parts[i], cars[i]);
+ Couple(parts[i], cars[i].TrainCar, autoCouple);
+
+ //update speed queue data
+ for (int i = 0; i < cars.Length; i++)
+ cars[i].Client_trainSpeedQueue.ReceiveSnapshot(parts[i].Speed, NetworkLifecycle.Instance.Tick);
}
- public static TrainCar SpawnCar(TrainsetSpawnPart spawnPart, bool preventCoupling = false)
+ public static NetworkedTrainCar SpawnCar(TrainsetSpawnPart spawnPart, bool preventCoupling = false)
{
if (!NetworkedRailTrack.Get(spawnPart.Bogie1.TrackNetId, out NetworkedRailTrack bogie1Track) && spawnPart.Bogie1.TrackNetId != 0)
{
@@ -38,24 +52,44 @@ public static TrainCar SpawnCar(TrainsetSpawnPart spawnPart, bool preventCouplin
return null;
}
- (TrainCar trainCar, bool isPooled) = GetFromPool(livery);
+ //TrainCar trainCar = CarSpawner.Instance.BaseSpawn(livery.prefab, spawnPart.PlayerSpawnedCar, false); //todo: do we need to set the unique flag ever on a client?
+ TrainCar trainCar = (CarSpawner.Instance.useCarPooling ? CarSpawner.Instance.GetFromPool(livery.prefab) : UnityEngine.Object.Instantiate(livery.prefab)).GetComponentInChildren();
+ //Multiplayer.LogDebug(() => $"SpawnCar({spawnPart.CarId}) activePrefab: {livery.prefab.activeSelf} activeInstance: {trainCar.gameObject.activeSelf}");
+ trainCar.playerSpawnedCar = spawnPart.PlayerSpawnedCar;
+ trainCar.uniqueCar = false;
+ trainCar.InitializeExistingLogicCar(spawnPart.CarId, spawnPart.CarGuid);
- NetworkedTrainCar networkedTrainCar = trainCar.gameObject.GetOrAddComponent();
- networkedTrainCar.NetId = spawnPart.NetId;
- trainCar.gameObject.GetOrAddComponent();
+ //Restoration vehicle hack
+ //todo: make it work properly
+ if (spawnPart.IsRestorationLoco)
+ switch(spawnPart.RestorationState)
+ {
+ case LocoRestorationController.RestorationState.S0_Initialized:
+ case LocoRestorationController.RestorationState.S1_UnlockedRestorationLicense:
+ case LocoRestorationController.RestorationState.S2_LocoUnblocked:
+ BlockLoco(trainCar);
- trainCar.gameObject.SetActive(true);
+ break;
+ }
- if (isPooled)
- trainCar.AwakeForPooledCar();
+ if (trainCar.PaintExterior != null && spawnPart.PaintExterior != null)
+ trainCar.PaintExterior.currentTheme = spawnPart.PaintExterior;
- trainCar.InitializeExistingLogicCar(spawnPart.CarId, spawnPart.CarGuid);
+ if (trainCar.PaintInterior != null && spawnPart.PaintInterior != null)
+ trainCar.PaintInterior.currentTheme = spawnPart.PaintInterior;
+ //Add networked components
+ NetworkedTrainCar networkedTrainCar = trainCar.gameObject.GetOrAddComponent();
+ networkedTrainCar.NetId = spawnPart.NetId;
+
+ //Setup positions and bogies
Transform trainTransform = trainCar.transform;
trainTransform.position = spawnPart.Position + WorldMover.currentMove;
- trainTransform.eulerAngles = spawnPart.Rotation;
- trainCar.playerSpawnedCar = spawnPart.PlayerSpawnedCar;
- trainCar.preventAutoCouple = true;
+ trainTransform.rotation = spawnPart.Rotation;
+
+ //Multiplayer.LogDebug(() => $"SpawnCar({spawnPart.CarId}) Bogie1 derailed: {spawnPart.Bogie1.HasDerailed}, Rail Track: {bogie1Track?.RailTrack?.name}, Position along track: {spawnPart.Bogie1.PositionAlongTrack}, Track direction: {spawnPart.Bogie1.TrackDirection}, " +
+ // $"Bogie2 derailed: {spawnPart.Bogie2.HasDerailed}, Rail Track: {bogie2Track?.RailTrack?.name}, Position along track: {spawnPart.Bogie2.PositionAlongTrack}, Track direction: {spawnPart.Bogie2.TrackDirection}"
+ //);
if (!spawnPart.Bogie1.HasDerailed)
trainCar.Bogies[0].SetTrack(bogie1Track.RailTrack, spawnPart.Bogie1.PositionAlongTrack, spawnPart.Bogie1.TrackDirection);
@@ -67,62 +101,133 @@ public static TrainCar SpawnCar(TrainsetSpawnPart spawnPart, bool preventCouplin
else
trainCar.Bogies[1].SetDerailedOnLoadFlag(true);
+ trainCar.TryAddFastTravelDestination();
+
CarSpawner.Instance.FireCarSpawned(trainCar);
- networkedTrainCar.Client_trainSpeedQueue.ReceiveSnapshot(spawnPart.Speed, NetworkLifecycle.Instance.Tick);
+ return networkedTrainCar;
+ }
+
+ private static void Couple(in TrainsetSpawnPart spawnPart, TrainCar trainCar, bool autoCouple)
+ {
+ TrainsetSpawnPart sp = spawnPart;
+ Multiplayer.LogDebug(() =>$"Couple([{sp.CarId}, {sp.NetId}], trainCar, {autoCouple})");
+
+ if (autoCouple)
+ {
+ trainCar.frontCoupler.preventAutoCouple = spawnPart.FrontCoupling.PreventAutoCouple;
+ trainCar.rearCoupler.preventAutoCouple = spawnPart.RearCoupling.PreventAutoCouple;
+
+ trainCar.frontCoupler.AttemptAutoCouple();
+ trainCar.rearCoupler.AttemptAutoCouple();
+
+ return;
+ }
- if (!preventCoupling)
- AutoCouple(spawnPart, trainCar);
+ //Handle coupling at front of car
+ HandleCoupling(spawnPart.FrontCoupling, trainCar.frontCoupler);
- return trainCar;
+ //Handle coupling at rear of car
+ HandleCoupling(spawnPart.RearCoupling, trainCar.rearCoupler);
}
- private static void AutoCouple(TrainsetSpawnPart spawnPart, TrainCar trainCar)
+ private static void HandleCoupling(CouplingData couplingData, Coupler currentCoupler)
{
- if (spawnPart.IsFrontCoupled) trainCar.frontCoupler.TryCouple(false, true);
- if (spawnPart.IsRearCoupled) trainCar.rearCoupler.TryCouple(false, true);
+
+ CouplingData cd = couplingData;
+ TrainCar tc = currentCoupler.train;
+ var net = tc.GetNetId();
+
+ Multiplayer.LogDebug(() => $"HandleCoupling([{tc?.ID}, {net}]) couplingData: is front: {currentCoupler.isFrontCoupler}, {couplingData.HoseConnected}, {couplingData.CockOpen}");
+
+ if (couplingData.IsCoupled)
+ {
+ if (!NetworkedTrainCar.GetTrainCar(couplingData.ConnectionNetId, out TrainCar otherCar))
+ {
+ Multiplayer.LogWarning($"HandleCoupling([{currentCoupler?.train?.ID}, {currentCoupler?.train?.GetNetId()}]) did not find car at {(currentCoupler.isFrontCoupler ? "Front" : "Rear")} car with netId: {couplingData.ConnectionNetId}");
+ }
+ else
+ {
+ var otherCoupler = couplingData.ConnectionToFront ? otherCar.frontCoupler : otherCar.rearCoupler;
+ SetCouplingState(currentCoupler, otherCoupler, couplingData.State);
+ }
+ }
+
+ CarsSaveManager.RestoreHoseAndCock(currentCoupler, couplingData.HoseConnected, couplingData.CockOpen);
}
- private static (TrainCar, bool) GetFromPool(TrainCarLivery livery)
+ public static void SetCouplingState(Coupler coupler, Coupler otherCoupler, ChainCouplerInteraction.State targetState)
{
- if (!CarSpawner.Instance.useCarPooling || !CarSpawner.Instance.carLiveryToTrainCarPool.TryGetValue(livery, out List trainCarList))
- return Instantiate(livery);
+ //Multiplayer.LogDebug(() => $"SetCouplingState({coupler.train.ID}, {otherCoupler.train.ID}, {targetState}) Coupled: {coupler.IsCoupled()}");
- int count = trainCarList.Count;
- if (count <= 0)
- return Instantiate(livery);
+ if (coupler.IsCoupled() && targetState == ChainCouplerInteraction.State.Attached_Tight)
+ {
+ //Multiplayer.LogDebug(() => $"SetCouplingState({coupler.train.ID}, {otherCoupler.train.ID}, {targetState}) Coupled, attaching tight");
+ coupler.state = ChainCouplerInteraction.State.Parked;
+ return;
+ }
- int index = count - 1;
- TrainCar trainCar = trainCarList[index];
- trainCarList.RemoveAt(index);
- CarSpawner.Instance.trainCarPoolHashSet.Remove(trainCar);
+ coupler.state = targetState;
+ if (coupler.state == ChainCouplerInteraction.State.Attached_Tight)
+ {
+ //Multiplayer.LogDebug(() => $"SetCouplingState({coupler.train.ID}, {otherCoupler.train.ID}, {targetState}) Not coupled, attaching tight");
+ coupler.CoupleTo(otherCoupler, false);
+ coupler.SetChainTight(true);
+ }
+ else if (coupler.state == ChainCouplerInteraction.State.Attached_Loose)
+ {
+ //Multiplayer.LogDebug(() => $"SetCouplingState({coupler.train.ID}, {otherCoupler.train.ID}, {targetState}) Unknown coupled, attaching loose");
+ coupler.CoupleTo(otherCoupler, false);
+ coupler.SetChainTight(false);
+ }
- if (trainCar != null)
+ if (!coupler.IsCoupled())
{
- Transform trainCarTransform = trainCar.transform;
- trainCarTransform.SetParent(null);
- trainCarTransform.localScale = Vector3.one;
- trainCar.gameObject.SetActive(false); // Enabled after NetworkedTrainCar has been added
-
- Transform interiorTransform = trainCar.interior.transform;
- interiorTransform.SetParent(null);
- interiorTransform.localScale = Vector3.one;
-
- trainCar.interior.gameObject.SetActive(true);
- trainCar.rb.isKinematic = false;
- return (trainCar, true);
+ //Multiplayer.LogDebug(() => $"SetCouplingState({coupler.train.ID}, {otherCoupler.train.ID}, {targetState}) Failed to couple, activating buffer collider");
+ coupler.fakeBuffersCollider.enabled = true;
}
- Multiplayer.LogError($"Failed to get {livery.id} from pool!");
- return Instantiate(livery);
}
- private static (TrainCar, bool) Instantiate(TrainCarLivery livery)
+ private static void SetBrakeParams(BrakeSystemData brakeSystemData, TrainCar trainCar)
{
- bool wasActive = livery.prefab.activeSelf;
- livery.prefab.SetActive(false);
- (TrainCar, bool) result = (Object.Instantiate(livery.prefab).GetComponent(), false);
- livery.prefab.SetActive(wasActive);
- return result;
+ BrakeSystem bs = trainCar.brakeSystem;
+
+ if (bs == null)
+ {
+ Multiplayer.LogWarning($"NetworkedCarSpawner.SetBrakeParams() Brake system is null! netId: {trainCar?.GetNetId()}, trainCar: {trainCar?.ID}");
+ return;
+ }
+
+ if(bs.hasHandbrake)
+ bs.SetHandbrakePosition(brakeSystemData.HandBrakePosition);
+ if(bs.hasTrainBrake)
+ bs.trainBrakePosition = brakeSystemData.TrainBrakePosition;
+
+ bs.SetBrakePipePressure(brakeSystemData.BrakePipePressure);
+ bs.SetAuxReservoirPressure(brakeSystemData.AuxResPressure);
+ bs.SetMainReservoirPressure(brakeSystemData.MainResPressure);
+ bs.SetControlReservoirPressure(brakeSystemData.ControlResPressure);
+ bs.ForceCylinderPressure(brakeSystemData.BrakeCylPressure);
+
+ }
+
+ private static void BlockLoco(TrainCar trainCar)
+ {
+ trainCar.blockInteriorLoading = true;
+ trainCar.preventFastTravelWithCar = true;
+ trainCar.preventFastTravelDestination = true;
+
+ if (trainCar.FastTravelDestination != null)
+ {
+ trainCar.FastTravelDestination.showOnMap = false;
+ trainCar.FastTravelDestination.RefreshMarkerVisibility();
+ }
+
+ trainCar.preventDebtDisplay = true;
+ trainCar.preventRerail = true;
+ trainCar.preventDelete = true;
+ trainCar.preventService = true;
+ trainCar.preventCouple = true;
}
}
diff --git a/Multiplayer/Components/Networking/Train/NetworkedRigidbody.cs b/Multiplayer/Components/Networking/Train/NetworkedRigidbody.cs
new file mode 100644
index 0000000..bd772c4
--- /dev/null
+++ b/Multiplayer/Components/Networking/Train/NetworkedRigidbody.cs
@@ -0,0 +1,61 @@
+using Multiplayer.Networking.Data.Train;
+using System;
+using System.Collections;
+using UnityEngine;
+using static Multiplayer.Networking.Data.Train.RigidbodySnapshot;
+
+namespace Multiplayer.Components.Networking.Train;
+
+public class NetworkedRigidbody : TickedQueue
+{
+ private const int MAX_FRAMES = 60;
+ private Rigidbody rigidbody;
+
+ protected override void OnEnable()
+ {
+ StartCoroutine(WaitForRB());
+ }
+
+ protected IEnumerator WaitForRB()
+ {
+ int counter = 0;
+
+ while (rigidbody == null && counter < MAX_FRAMES)
+ {
+ rigidbody = GetComponent();
+ if (rigidbody == null)
+ {
+ counter++;
+ yield return new WaitForEndOfFrame();
+ }
+ }
+
+ base.OnEnable();
+
+ if (rigidbody == null)
+ {
+ gameObject.TryGetComponent(out TrainCar car);
+
+ Multiplayer.LogError($"{gameObject.name} ({car?.ID}): {nameof(NetworkedBogie)} requires a {nameof(Bogie)} component on the same GameObject! Waited {counter} iterations");
+ }
+ }
+
+ protected override void Process(RigidbodySnapshot snapshot, uint snapshotTick)
+ {
+ if (snapshot == null)
+ {
+ Multiplayer.LogError($"NetworkedRigidBody.Process() Snapshot NULL!");
+ return;
+ }
+
+ try
+ {
+ //Multiplayer.LogDebug(() => $"NetworkedRigidBody.Process() {(IncludedData)snapshot.IncludedDataFlags}, {snapshot.Position.ToString() ?? "null"}, {snapshot.Rotation.ToString() ?? "null"}, {snapshot.Velocity.ToString() ?? "null"}, {snapshot.AngularVelocity.ToString() ?? "null"}, tick: {snapshotTick}");
+ snapshot.Apply(rigidbody);
+ }
+ catch (Exception ex)
+ {
+ Multiplayer.LogError($"NetworkedRigidBody.Process() {ex.Message}\r\n {ex.StackTrace}");
+ }
+ }
+}
diff --git a/Multiplayer/Components/Networking/Train/NetworkedTrainCar.cs b/Multiplayer/Components/Networking/Train/NetworkedTrainCar.cs
index 436649c..5964288 100644
--- a/Multiplayer/Components/Networking/Train/NetworkedTrainCar.cs
+++ b/Multiplayer/Components/Networking/Train/NetworkedTrainCar.cs
@@ -1,15 +1,21 @@
+using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
+using DV.Customization.Paint;
+using DV.MultipleUnit;
using DV.Simulation.Brake;
using DV.Simulation.Cars;
using DV.ThingTypes;
+using JetBrains.Annotations;
using LocoSim.Definitions;
using LocoSim.Implementations;
using Multiplayer.Components.Networking.Player;
-using Multiplayer.Components.Networking.World;
using Multiplayer.Networking.Data;
+using Multiplayer.Networking.Data.Train;
+using Multiplayer.Networking.Packets.Clientbound.Train;
using Multiplayer.Networking.Packets.Common.Train;
+using Multiplayer.Networking.TransportLayers;
using Multiplayer.Utils;
using UnityEngine;
@@ -19,8 +25,10 @@ public class NetworkedTrainCar : IdMonoBehaviour
{
#region Lookup Cache
- private static readonly Dictionary trainCarsToNetworkedTrainCars = new();
- private static readonly Dictionary hoseToCoupler = new();
+ private static readonly Dictionary trainCarsToNetworkedTrainCars = [];
+ private static readonly Dictionary trainCarIdToNetworkedTrainCars = [];
+ private static readonly Dictionary trainCarIdToTrainCars = [];
+ private static readonly Dictionary hoseToCoupler = [];
public static bool Get(ushort netId, out NetworkedTrainCar obj)
{
@@ -36,14 +44,18 @@ public static bool GetTrainCar(ushort netId, out TrainCar obj)
return b;
}
- public static Coupler GetCoupler(HoseAndCock hoseAndCock)
+ public static bool TryGetCoupler(HoseAndCock hoseAndCock, out Coupler coupler)
{
- return hoseToCoupler[hoseAndCock];
+ return hoseToCoupler.TryGetValue(hoseAndCock, out coupler);
}
- public static NetworkedTrainCar GetFromTrainCar(TrainCar trainCar)
+ public static bool GetFromTrainId(string carId, out NetworkedTrainCar networkedTrainCar)
{
- return trainCarsToNetworkedTrainCars[trainCar];
+ return trainCarIdToNetworkedTrainCars.TryGetValue(carId, out networkedTrainCar);
+ }
+ public static bool GetTrainCarFromTrainId(string carId, out TrainCar trainCar)
+ {
+ return trainCarIdToTrainCars.TryGetValue(carId, out trainCar);
}
public static bool TryGetFromTrainCar(TrainCar trainCar, out NetworkedTrainCar networkedTrainCar)
@@ -53,7 +65,14 @@ public static bool TryGetFromTrainCar(TrainCar trainCar, out NetworkedTrainCar n
#endregion
+ private const int MAX_COUPLER_ITERATIONS = 10;
+ private const float MAX_FIREBOX_DELTA = 0.1f;
+ private const float MAX_PORT_DELTA = 0.001f;
+
+
+ public string CurrentID { get; private set; }
public TrainCar TrainCar;
+ public uint TicksSinceSync = uint.MaxValue;
public bool HasPlayers => PlayerManager.Car == TrainCar || GetComponentInChildren() != null;
private Bogie bogie1;
@@ -62,30 +81,46 @@ public static bool TryGetFromTrainCar(TrainCar trainCar, out NetworkedTrainCar n
private bool hasSimFlow;
private SimulationFlow simulationFlow;
+ public FireboxSimController firebox;
private HashSet dirtyPorts;
private Dictionary lastSentPortValues;
private HashSet dirtyFuses;
+ private float lastSentFireboxValue;
+
private bool handbrakeDirty;
+ private bool mainResPressureDirty;
+ private bool brakeOverheatDirty;
+
public bool BogieTracksDirty;
- public int Bogie1TrackDirection;
- public int Bogie2TrackDirection;
private bool cargoDirty;
private bool cargoIsLoading;
public byte CargoModelIndex = byte.MaxValue;
private bool healthDirty;
private bool sendCouplers;
+ private bool sendCables;
+ private bool fireboxDirty;
public bool IsDestroying;
+ //Coupler interaction
+ private bool frontInteracting = false;
+ private bool rearInteracting = false;
+
+ private int frontInteractionPeer;
+ private int rearInteractionPeer;
#region Client
- private bool client_Initialized;
+ public bool Client_Initialized {get; private set;}
public TickedQueue Client_trainSpeedQueue;
public TickedQueue Client_trainRigidbodyQueue;
- private TickedQueue client_bogie1Queue;
- private TickedQueue client_bogie2Queue;
+ public TickedQueue client_bogie1Queue;
+ public TickedQueue client_bogie2Queue;
+
+ private Coupler couplerInteraction;
+ private ChainCouplerInteraction.State originalState;
+ private Coupler originalCoupledTo;
#endregion
protected override bool IsIdServerAuthoritative => true;
@@ -97,6 +132,8 @@ protected override void Awake()
TrainCar = GetComponent();
trainCarsToNetworkedTrainCars[TrainCar] = this;
+ TrainCar.LogicCarInitialized += OnLogicCarInitialised;
+
bogie1 = TrainCar.Bogies[0];
bogie2 = TrainCar.Bogies[1];
@@ -112,13 +149,22 @@ protected override void Awake()
}
}
- private void Start()
+ [UsedImplicitly]
+ public void Start()
{
brakeSystem = TrainCar.brakeSystem;
foreach (Coupler coupler in TrainCar.couplers)
+ {
hoseToCoupler[coupler.hoseAndCock] = coupler;
+ Multiplayer.LogDebug(() => $"TrainCar.Start() [{TrainCar?.ID}, {NetId}], Coupler exists: {coupler != null}, Is front: {coupler.isFrontCoupler}, ChainScript exists: {coupler.ChainScript != null}");
+
+ //Locos with tenders and tenders only have one chainscript each, no trainscript is used for the hitch between the loco and tender
+ if(coupler.ChainScript != null)
+ coupler.ChainScript.StateChanged += (state) => { Client_CouplerStateChange(state, coupler); };
+ }
+
SimController simController = GetComponent();
if (simController != null)
{
@@ -130,43 +176,105 @@ private void Start()
foreach (KeyValuePair kvp in simulationFlow.fullPortIdToPort)
if (kvp.Value.valueType == PortValueType.CONTROL || NetworkLifecycle.Instance.IsHost())
kvp.Value.ValueUpdatedInternally += _ => { Common_OnPortUpdated(kvp.Value); };
-
+
dirtyFuses = new HashSet(simulationFlow.fullFuseIdToFuse.Count);
foreach (KeyValuePair kvp in simulationFlow.fullFuseIdToFuse)
kvp.Value.StateUpdated += _ => { Common_OnFuseUpdated(kvp.Value); };
+
+ if (simController.firebox != null)
+ {
+ firebox = simController.firebox;
+ firebox.fireboxCoalControlPort.ValueUpdatedInternally += Client_OnAddCoal; //Player adding coal
+ firebox.fireboxIgnitionPort.ValueUpdatedInternally += Client_OnIgnite; //Player igniting firebox
+ }
}
-
+
brakeSystem.HandbrakePositionChanged += Common_OnHandbrakePositionChanged;
brakeSystem.BrakeCylinderReleased += Common_OnBrakeCylinderReleased;
+
+ if (TrainCar.PaintExterior != null)
+ TrainCar.PaintExterior.OnThemeChanged += Common_OnPaintThemeChange;
+ if (TrainCar.PaintInterior != null)
+ TrainCar.PaintInterior.OnThemeChanged += Common_OnPaintThemeChange;
+
NetworkLifecycle.Instance.OnTick += Common_OnTick;
if (NetworkLifecycle.Instance.IsHost())
{
NetworkLifecycle.Instance.OnTick += Server_OnTick;
+ NetworkLifecycle.Instance.Server.PlayerDisconnect += Server_OnPlayerDisconnect;
+
bogie1.TrackChanged += Server_BogieTrackChanged;
bogie2.TrackChanged += Server_BogieTrackChanged;
TrainCar.CarDamage.CarEffectiveHealthStateUpdate += Server_CarHealthUpdate;
+
+ brakeSystem.MainResPressureChanged += Server_MainResUpdate;
+ brakeSystem.heatController.OverheatingActiveStateChanged += Server_BrakeHeatUpdate;
+
+ if (firebox != null)
+ {
+ firebox.fireboxContentsPort.ValueUpdatedInternally += Common_OnFireboxUpdate;
+ firebox.fireOnPort.ValueUpdatedInternally += Common_OnFireboxUpdate;
+ }
+
StartCoroutine(Server_WaitForLogicCar());
}
- }
- private void OnDisable()
+ NetworkLifecycle.Instance?.Client.SendTrainSyncRequest(NetId);
+ }
+ public void OnDisable()
{
if (UnloadWatcher.isQuitting)
return;
+
NetworkLifecycle.Instance.OnTick -= Common_OnTick;
NetworkLifecycle.Instance.OnTick -= Server_OnTick;
- if (UnloadWatcher.isUnloading)
- return;
+ //if (UnloadWatcher.isUnloading)
+ // return;
+
trainCarsToNetworkedTrainCars.Remove(TrainCar);
+
+ trainCarIdToNetworkedTrainCars.Remove(CurrentID);
+ trainCarIdToTrainCars.Remove(CurrentID);
+
foreach (Coupler coupler in TrainCar.couplers)
hoseToCoupler.Remove(coupler.hoseAndCock);
- brakeSystem.HandbrakePositionChanged -= Common_OnHandbrakePositionChanged;
- brakeSystem.BrakeCylinderReleased -= Common_OnBrakeCylinderReleased;
+
+ if (firebox != null)
+ {
+ firebox.fireboxCoalControlPort.ValueUpdatedInternally -= Client_OnAddCoal; //Player adding coal
+ firebox.fireboxIgnitionPort.ValueUpdatedInternally -= Client_OnIgnite; //Player igniting firebox
+ }
+
+ if (brakeSystem != null)
+ {
+ brakeSystem.HandbrakePositionChanged -= Common_OnHandbrakePositionChanged;
+ brakeSystem.BrakeCylinderReleased -= Common_OnBrakeCylinderReleased;
+ }
+
+ if(TrainCar.PaintExterior != null)
+ TrainCar.PaintExterior.OnThemeChanged -= Common_OnPaintThemeChange;
+ if (TrainCar.PaintInterior != null)
+ TrainCar.PaintInterior.OnThemeChanged -= Common_OnPaintThemeChange;
+
if (NetworkLifecycle.Instance.IsHost())
{
bogie1.TrackChanged -= Server_BogieTrackChanged;
bogie2.TrackChanged -= Server_BogieTrackChanged;
+
TrainCar.CarDamage.CarEffectiveHealthStateUpdate -= Server_CarHealthUpdate;
+
+ if(brakeSystem != null)
+ {
+ brakeSystem.MainResPressureChanged -= Server_MainResUpdate;
+ brakeSystem.heatController.OverheatingActiveStateChanged -= Server_BrakeHeatUpdate;
+ }
+
+ if (firebox != null)
+ {
+ firebox.fireboxContentsPort.ValueUpdatedInternally -= Common_OnFireboxUpdate;
+ firebox.fireOnPort.ValueUpdatedInternally -= Common_OnFireboxUpdate;
+ }
+
if (TrainCar.logicCar != null)
{
TrainCar.logicCar.CargoLoaded -= Server_OnCargoLoaded;
@@ -174,35 +282,61 @@ private void OnDisable()
}
}
+ CurrentID = string.Empty;
Destroy(this);
}
#region Server
+ private void OnLogicCarInitialised()
+ {
+ //Multiplayer.LogWarning("OnLogicCarInitialised");
+ if (TrainCar.logicCar != null)
+ {
+ CurrentID = TrainCar.ID;
+ trainCarIdToNetworkedTrainCars[CurrentID] = this;
+ trainCarIdToTrainCars[CurrentID] = TrainCar;
+
+ TrainCar.LogicCarInitialized -= OnLogicCarInitialised;
+ }
+ else
+ {
+ Multiplayer.LogWarning("OnLogicCarInitialised Car Not Initialised!");
+ }
+
+ }
private IEnumerator Server_WaitForLogicCar()
{
while (TrainCar.logicCar == null)
yield return null;
+
TrainCar.logicCar.CargoLoaded += Server_OnCargoLoaded;
TrainCar.logicCar.CargoUnloaded += Server_OnCargoUnloaded;
- NetworkLifecycle.Instance.Server.SendSpawnTrainCar(this);
+
+ Server_DirtyAllState();
}
public void Server_DirtyAllState()
{
handbrakeDirty = true;
+ mainResPressureDirty = true;
cargoDirty = true;
cargoIsLoading = true;
healthDirty = true;
BogieTracksDirty = true;
sendCouplers = true;
+ sendCables = true;
+ fireboxDirty = firebox != null; //only dirty if exists
+
if (!hasSimFlow)
return;
foreach (string portId in simulationFlow.fullPortIdToPort.Keys)
{
dirtyPorts.Add(portId);
- if (simulationFlow.TryGetPort(portId, out Port port))
- lastSentPortValues[portId] = port.value;
+ //if (simulationFlow.TryGetPort(portId, out Port port))
+ //{
+ // lastSentPortValues[portId] = port.value;
+ //}
}
foreach (string fuseId in simulationFlow.fullFuseIdToFuse.Keys)
@@ -214,11 +348,18 @@ public bool Server_ValidateClientSimFlowPacket(ServerPlayer player, CommonTrainP
// Only allow control ports to be updated by clients
if (hasSimFlow)
foreach (string portId in packet.PortIds)
- if (simulationFlow.TryGetPort(portId, out Port port) && port.valueType != PortValueType.CONTROL)
+ if (simulationFlow.TryGetPort(portId, out Port port))
{
- NetworkLifecycle.Instance.Server.LogWarning($"Player {player.Username} tried to send a non-control port!");
- Common_DirtyPorts(packet.PortIds);
- return false;
+ if (port.valueType != PortValueType.CONTROL)
+ {
+ NetworkLifecycle.Instance.Server.LogWarning($"Player {player.Username} tried to send a non-control port! ({portId} on [{TrainCar?.ID}, {NetId}])");
+ Common_DirtyPorts(packet.PortIds);
+ return false;
+ }
+ }
+ else
+ {
+ NetworkLifecycle.Instance.Server.LogWarning($"Player {player.Username} sent portId: {portId}, value type: {port.valueType}, but the port was not found");
}
// Only allow the player to update ports on the car they are in/near
@@ -259,21 +400,72 @@ private void Server_CarHealthUpdate(float health)
healthDirty = true;
}
+ private void Server_MainResUpdate(float normalizedPressure, float pressure)
+ {
+ mainResPressureDirty = true;
+ }
+
+ private void Server_BrakeHeatUpdate(bool overheatActive)
+ {
+ brakeOverheatDirty = true;
+ }
+
+ private void Server_FireboxUpdate(float normalizedPressure, float pressure)
+ {
+ fireboxDirty = true;
+ }
+
private void Server_OnTick(uint tick)
{
if (UnloadWatcher.isUnloading)
return;
- Server_SendCouplers();
+
+ Server_SendBrakeStates();
+ Server_SendFireBoxState();
+ //Server_SendCouplers();
+ Server_SendCables();
Server_SendCargoState();
Server_SendHealthState();
+
+ TicksSinceSync++; //keep track of last full sync
+ }
+
+ private void Server_SendBrakeStates()
+ {
+ if (!mainResPressureDirty && !brakeOverheatDirty)
+ return;
+
+ mainResPressureDirty = false;
+ var hc = brakeSystem.heatController;
+ NetworkLifecycle.Instance.Server.SendBrakeState(
+ NetId,
+ brakeSystem.mainReservoirPressure, brakeSystem.brakePipePressure, brakeSystem.brakeCylinderPressure,
+ hc.overheatPercentage, hc.overheatReductionFactor, hc.temperature
+ );
+ }
+
+ private void Server_SendFireBoxState()
+ {
+ if (!fireboxDirty || firebox == null)
+ return;
+
+ fireboxDirty = false;
+ NetworkLifecycle.Instance.Server.SendFireboxState(NetId, firebox.fireboxContentsPort.value, firebox.IsFireOn);
}
private void Server_SendCouplers()
{
if (!sendCouplers)
return;
+
sendCouplers = false;
+ if(TrainCar.frontCoupler.IsCoupled())
+ NetworkLifecycle.Instance.Client.SendTrainCouple(TrainCar.frontCoupler,TrainCar.frontCoupler.coupledTo,false, false);
+
+ if(TrainCar.rearCoupler.IsCoupled())
+ NetworkLifecycle.Instance.Client.SendTrainCouple(TrainCar.rearCoupler,TrainCar.rearCoupler.coupledTo,false, false);
+
if (TrainCar.frontCoupler.hoseAndCock.IsHoseConnected)
NetworkLifecycle.Instance.Client.SendHoseConnected(TrainCar.frontCoupler, TrainCar.frontCoupler.coupledTo, false);
@@ -283,6 +475,21 @@ private void Server_SendCouplers()
NetworkLifecycle.Instance.Client.SendCockState(NetId, TrainCar.frontCoupler, TrainCar.frontCoupler.IsCockOpen);
NetworkLifecycle.Instance.Client.SendCockState(NetId, TrainCar.rearCoupler, TrainCar.rearCoupler.IsCockOpen);
}
+ private void Server_SendCables()
+ {
+ if (!sendCables)
+ return;
+ sendCables = false;
+
+ if(TrainCar.muModule == null)
+ return;
+
+ if (TrainCar.muModule.frontCable.IsConnected)
+ NetworkLifecycle.Instance.Client.SendMuConnected(TrainCar.muModule.frontCable, TrainCar.muModule.frontCable.connectedTo, false);
+
+ if (TrainCar.muModule.rearCable.IsConnected)
+ NetworkLifecycle.Instance.Client.SendMuConnected(TrainCar.muModule.rearCable, TrainCar.muModule.rearCable.connectedTo, false);
+ }
private void Server_SendCargoState()
{
@@ -302,6 +509,67 @@ private void Server_SendHealthState()
NetworkLifecycle.Instance.Server.SendCarHealthUpdate(NetId, TrainCar.CarDamage.currentHealth);
}
+ public bool Server_ValidateCouplerInteraction(CommonCouplerInteractionPacket packet, ITransportPeer peer)
+ {
+ Multiplayer.LogDebug(() =>
+ $"Server_ValidateCouplerInteraction([[{(CouplerInteractionType)packet.Flags}], {CurrentID}, {packet.NetId}], {peer.Id}) " +
+ $"isFront: {packet.IsFrontCoupler}, frontInteracting: {frontInteracting}, frontInteractionPeer: {frontInteractionPeer}, " +
+ $"rearInteracting: {rearInteracting}, rearInteractionPeer: {rearInteractionPeer}"
+ );
+ //Ensure no one else is interacting
+ if (packet.IsFrontCoupler && frontInteracting && peer.Id != frontInteractionPeer ||
+ packet.IsFrontCoupler == false && rearInteracting && peer.Id != rearInteractionPeer)
+ {
+ Multiplayer.LogDebug(() => $"Server_ValidateCouplerInteraction([{packet.Flags}, {CurrentID}, {packet.NetId}], {peer.Id}) Failed to validate!");
+ return false;
+ }
+
+ Multiplayer.LogDebug(() => $"Server_ValidateCouplerInteraction([{packet.Flags}, {CurrentID}, {packet.NetId}], {peer.Id}) No one interacting");
+
+ if (((CouplerInteractionType)packet.Flags).HasFlag(CouplerInteractionType.Start))
+ {
+ if (packet.IsFrontCoupler)
+ {
+ frontInteracting = true;
+ frontInteractionPeer = peer.Id;
+ }
+ else
+ {
+ rearInteracting = true;
+ rearInteractionPeer = peer.Id;
+ }
+ }
+ else
+ {
+ if (packet.IsFrontCoupler)
+ frontInteracting = false;
+ else
+ rearInteracting = false;
+ }
+
+ //todo: Additional checks for player location/proximity
+
+ Multiplayer.LogDebug(() => $"Server_ValidateCouplerInteraction([{packet.Flags}, {CurrentID}, {packet.NetId}], {peer.Id}) Validation passed!");
+ return true;
+ }
+
+ private void Server_OnPlayerDisconnect(uint id)
+ {
+ //todo: resove player disconnection during chain interaction
+ if (frontInteractionPeer == id || rearInteractionPeer == id)
+ {
+ Multiplayer.LogWarning($"Server_OnPlayerDisconnect() Coupler interaction in unknown state [{CurrentID}, {NetId}] isFront: {frontInteractionPeer == id}");
+ if (frontInteractionPeer == id)
+ {
+ frontInteracting = false ;
+ //NetworkLifecycle.Instance.Client.SendCouplerInteraction(cou, coupler, otherCoupler);
+ }
+ else
+ {
+ rearInteracting = false;
+ }
+ }
+ }
#endregion
#region Common
@@ -310,6 +578,7 @@ private void Common_OnTick(uint tick)
{
if (UnloadWatcher.isUnloading)
return;
+
Common_SendHandbrakePosition();
Common_SendFuses();
Common_SendPorts();
@@ -321,6 +590,7 @@ private void Common_SendHandbrakePosition()
return;
if (!TrainCar.brakeSystem.hasHandbrake)
return;
+
handbrakeDirty = false;
NetworkLifecycle.Instance.Client.SendHandbrakePositionChanged(NetId, brakeSystem.handbrakePosition);
}
@@ -334,6 +604,8 @@ public void Common_DirtyPorts(string[] portIds)
{
if (!simulationFlow.TryGetPort(portId, out Port _))
{
+
+ Multiplayer.LogWarning($"Tried to dirty port {portId} on UNKNOWN but it doesn't exist!");
Multiplayer.LogWarning($"Tried to dirty port {portId} on {TrainCar.ID} but it doesn't exist!");
continue;
}
@@ -351,6 +623,7 @@ public void Common_DirtyFuses(string[] fuseIds)
{
if (!simulationFlow.TryGetFuse(fuseId, out Fuse _))
{
+ Multiplayer.LogWarning($"Tried to dirty port {fuseId} on UNKOWN but it doesn't exist!");
Multiplayer.LogWarning($"Tried to dirty port {fuseId} on {TrainCar.ID} but it doesn't exist!");
continue;
}
@@ -369,9 +642,18 @@ private void Common_SendPorts()
float[] portValues = new float[portIds.Length];
foreach (string portId in dirtyPorts)
{
- float value = simulationFlow.fullPortIdToPort[portId].Value;
- portValues[i++] = value;
- lastSentPortValues[portId] = value;
+ if(simulationFlow.TryGetPort(portId, out Port port))
+ {
+ float value = port.Value;
+ portValues[i] = value;
+ lastSentPortValues[portId] = value;
+ }
+ else
+ {
+ Multiplayer.LogWarning($"Failed to send port \"{portId}\" for [{CurrentID}, {NetId}]");
+ }
+
+ i++;
}
dirtyPorts.Clear();
@@ -387,8 +669,16 @@ private void Common_SendFuses()
int i = 0;
string[] fuseIds = dirtyFuses.ToArray();
bool[] fuseValues = new bool[fuseIds.Length];
+
foreach (string fuseId in dirtyFuses)
- fuseValues[i++] = simulationFlow.fullFuseIdToFuse[fuseId].State;
+ {
+ if(simulationFlow.TryGetFuse(fuseId, out Fuse fuse))
+ fuseValues[i] = fuse.State;
+ else
+ Multiplayer.LogWarning($"SendFuses() [{CurrentID}, {NetId}] Failed to find fuse \"{fuseId}\"");
+
+ i++;
+ }
dirtyFuses.Clear();
@@ -409,21 +699,65 @@ private void Common_OnBrakeCylinderReleased()
NetworkLifecycle.Instance.Client.SendBrakeCylinderReleased(NetId);
}
+ private void Common_OnFireboxUpdate(float newFireboxValue)
+ {
+ if (NetworkLifecycle.Instance.IsProcessingPacket)
+ return;
+
+ var delta = Math.Abs(lastSentFireboxValue - newFireboxValue);
+ if (delta > MAX_FIREBOX_DELTA || (newFireboxValue == 0 && lastSentFireboxValue != 0))
+ {
+ fireboxDirty = true;
+ lastSentFireboxValue = newFireboxValue;
+ }
+
+ }
+
private void Common_OnPortUpdated(Port port)
{
if (UnloadWatcher.isUnloading || NetworkLifecycle.Instance.IsProcessingPacket)
return;
if (float.IsNaN(port.prevValue) && float.IsNaN(port.Value))
return;
- if (lastSentPortValues.TryGetValue(port.id, out float value) && Mathf.Abs(value - port.Value) < 0.001f)
+
+ bool hasLastSent = lastSentPortValues.TryGetValue(port.id, out float lastSentValue);
+ float delta = Mathf.Abs(lastSentValue - port.Value);
+
+ if (port.valueType == PortValueType.STATE)
+ {
+ if (!hasLastSent || lastSentValue != port.Value)
+ {
+ dirtyPorts.Add(port.id);
+ }
+ }
+ else
+ {
+ if (!hasLastSent || delta > MAX_PORT_DELTA || (port.Value == 0 && lastSentValue != 0))
+ {
+ dirtyPorts.Add(port.id);
+ }
+ }
+ }
+
+ private void Common_OnPaintThemeChange(TrainCarPaint paintController)
+ {
+ if(paintController == null)
return;
- dirtyPorts.Add(port.id);
+
+ Multiplayer.LogDebug(() => $"Common_OnPaintThemeChange() target: {paintController.TargetArea}, theme: {paintController.CurrentTheme.name}");
+
+ byte target = (byte)paintController.TargetArea;
+ var theme = PaintThemeLookup.Instance.GetThemeIndex(paintController.CurrentTheme);
+
+ Multiplayer.LogDebug(() => $"Common_OnPaintThemeChange() sending [{CurrentID},{NetId}], target: {paintController.TargetArea}, theme: [{paintController.CurrentTheme.name},{theme}]");
+ NetworkLifecycle.Instance?.Client.SendPaintThemeChangePacket(NetId,target,theme);
}
private void Common_OnFuseUpdated(Fuse fuse)
{
if (UnloadWatcher.isUnloading || NetworkLifecycle.Instance.IsProcessingPacket)
return;
+
dirtyFuses.Add(fuse.id);
}
@@ -434,13 +768,21 @@ public void Common_UpdatePorts(CommonTrainPortsPacket packet)
for (int i = 0; i < packet.PortIds.Length; i++)
{
- Port port = simulationFlow.fullPortIdToPort[packet.PortIds[i]];
- float value = packet.PortValues[i];
- if (port.type == PortType.EXTERNAL_IN)
- port.ExternalValueUpdate(value);
+ if (simulationFlow.TryGetPort(packet.PortIds[i], out Port port))
+ {
+ float value = packet.PortValues[i];
+
+ if (port.type == PortType.EXTERNAL_IN)
+ port.ExternalValueUpdate(value);
+ else
+ port.Value = value;
+ }
else
- port.Value = value;
+ {
+ Multiplayer.LogWarning($"Common_UpdatePorts() [{CurrentID}, {NetId}] Failed to find port \"{packet.PortIds[i]}\", Value: {packet.PortValues[i]}");
+ }
}
+
}
public void Common_UpdateFuses(CommonTrainFusesPacket packet)
@@ -449,9 +791,385 @@ public void Common_UpdateFuses(CommonTrainFusesPacket packet)
return;
for (int i = 0; i < packet.FuseIds.Length; i++)
- simulationFlow.fullFuseIdToFuse[packet.FuseIds[i]].ChangeState(packet.FuseValues[i]);
+ if (simulationFlow.TryGetFuse(packet.FuseIds[i], out Fuse fuse))
+ fuse.ChangeState(packet.FuseValues[i]);
+ else
+ Multiplayer.LogWarning($"UpdateFuses() [{CurrentID}, {NetId}] Failed to find fuse \"{packet.FuseIds[i]}\", Value: {packet.FuseValues[i]}");
}
+ public void Common_ReceiveCouplerInteraction(CommonCouplerInteractionPacket packet)
+ {
+ CouplerInteractionType flags = (CouplerInteractionType)packet.Flags;
+ Coupler coupler = packet.IsFrontCoupler ? TrainCar?.frontCoupler : TrainCar?.rearCoupler;
+ TrainCar otherCar = null;
+ Coupler otherCoupler = null;
+
+ Multiplayer.LogDebug(() => $"Common_ReceiveCouplerInteraction() couplerNetId: {NetId}, coupler is front: {packet.IsFrontCoupler}, flags: {flags}, otherCouplerNetId: {packet.OtherNetId}, otherCoupler is front: {packet.IsFrontOtherCoupler}");
+
+ if (coupler == null)
+ {
+ Multiplayer.LogWarning($"Common_ReceiveCouplerInteraction() did not find coupler for [{TrainCar?.ID}, {NetId}], coupler is front: {packet.IsFrontCoupler}");
+ return;
+ }
+
+ if (packet.OtherNetId != 0)
+ {
+ if (GetTrainCar(packet.OtherNetId, out otherCar))
+ otherCoupler = packet.IsFrontOtherCoupler ? otherCar?.frontCoupler : otherCar?.rearCoupler;
+ }
+
+ Multiplayer.LogDebug(() => $"Common_ReceiveCouplerInteraction() [{TrainCar?.ID}, {NetId}], coupler is front: {packet.IsFrontCoupler}, flags: {flags}, otherCouplerNetId: {packet.OtherNetId}");
+
+ if (flags == CouplerInteractionType.NoAction)
+ {
+ Multiplayer.LogDebug(() => $"Common_ReceiveCouplerInteraction() Interaction rejected! [{CurrentID}, {NetId}]");
+ //our interaction was denied
+ coupler.ChainScript?.knobGizmo?.ForceEndInteraction();
+ couplerInteraction = null;
+
+ if (coupler.ChainScript.state == originalState)
+ return;
+
+ switch (originalState)
+ {
+ case ChainCouplerInteraction.State.Parked:
+ StartCoroutine(ParkCoupler(coupler));
+ break;
+ case ChainCouplerInteraction.State.Dangling:
+ if (coupler.ChainScript.state == ChainCouplerInteraction.State.Attached_Tight)
+ coupler.ChainScript.fsm.Fire(ChainCouplerInteraction.Trigger.Screw_Used);
+
+ StartCoroutine(DangleCoupler(coupler));
+ break;
+ case ChainCouplerInteraction.State.Attached_Loose:
+ if(coupler.ChainScript.state == ChainCouplerInteraction.State.Attached_Tight)
+ coupler.ChainScript.fsm.Fire(ChainCouplerInteraction.Trigger.Screw_Used);
+ else
+ StartCoroutine(LooseAttachCoupler(coupler, originalCoupledTo));
+ break;
+ case ChainCouplerInteraction.State.Attached_Tight:
+ if (coupler.ChainScript.state != ChainCouplerInteraction.State.Attached_Loose)
+ StartCoroutine(LooseAttachCoupler(coupler, originalCoupledTo));
+
+ coupler.ChainScript.fsm.Fire(ChainCouplerInteraction.Trigger.Screw_Used);
+ break;
+ default:
+ Multiplayer.LogDebug(() => $"Common_ReceiveCouplerInteraction() Unable to return to last state! {originalState}");
+ break;
+ }
+ return;
+ }
+ if (flags == CouplerInteractionType.Start && coupler != couplerInteraction)
+ {
+ Multiplayer.LogDebug(() => $"Common_ReceiveCouplerInteraction() Interaction started [{CurrentID}, {NetId}] isFront: {coupler.isFrontCoupler}");
+ //We've received a start signal for a coupler we aren't interacting with
+ //Another player must be interacting, so let's block us from tampering with it
+ if (coupler?.ChainScript?.knobGizmo)
+ coupler.ChainScript.knobGizmo.InteractionAllowed = false;
+ if(coupler?.ChainScript?.screwButtonBase)
+ coupler.ChainScript.screwButtonBase.InteractionAllowed = false;
+
+ return;
+ }
+
+ if (coupler.ChainScript.state == ChainCouplerInteraction.State.Being_Dragged)
+ {
+ Multiplayer.LogDebug(() => $"Common_ReceiveCouplerInteraction() [{TrainCar?.ID}, {NetId}], coupler is front: {packet.IsFrontCoupler}, flags: {flags}, otherCouplerNetId: {packet.OtherNetId} Being Dragged!");
+ coupler.ChainScript?.knobGizmo?.ForceEndInteraction();
+ }
+
+ if (flags.HasFlag(CouplerInteractionType.CouplerCouple) && packet.OtherNetId != 0)
+ {
+ Multiplayer.LogDebug(() => $"1 Common_ReceiveCouplerInteraction() [{TrainCar?.ID}, {NetId}], coupler is front: {packet.IsFrontCoupler}, flags: {flags} ");
+ if (otherCar != null)
+ {
+ Multiplayer.LogDebug(() => $"2 Common_ReceiveCouplerInteraction() [{TrainCar?.ID}, {NetId}], coupler is front: {packet.IsFrontCoupler}, flags: {flags}");
+ StartCoroutine(LooseAttachCoupler(coupler, otherCoupler));
+ }
+ }
+
+ if (flags.HasFlag(CouplerInteractionType.CouplerPark))
+ {
+ Multiplayer.LogDebug(() => $"3 Common_ReceiveCouplerInteraction() [{TrainCar?.ID}, {NetId}], coupler is front: {packet.IsFrontCoupler}, flags: {flags}, current state: {coupler.state}, Chain state:{coupler.ChainScript.state}, isCoupled: {coupler.IsCoupled()}");
+
+ if (coupler.ChainScript.state != ChainCouplerInteraction.State.Attached_Tight)
+ StartCoroutine(ParkCoupler(coupler));
+ else
+ Multiplayer.LogWarning(() => $"Received Park interaction for [{TrainCar?.ID}, {NetId}], coupler is front: {packet.IsFrontCoupler}, but coupler is in the wrong state: {coupler.state}, Chain state:{coupler.ChainScript.state}, isCoupled: {coupler.IsCoupled()}");
+
+ Multiplayer.LogDebug(() => $"4 Common_ReceiveCouplerInteraction() [{TrainCar?.ID}, {NetId}], coupler is front: {packet.IsFrontCoupler}, flags: {flags} restorestate: {coupler.state}, current state: {coupler.state}, Chain state:{coupler.ChainScript.state}, isCoupled: {coupler.IsCoupled()}");
+ }
+
+ if (flags.HasFlag(CouplerInteractionType.CouplerDrop))
+ {
+ Multiplayer.LogDebug(() => $"5 Common_ReceiveCouplerInteraction() [{TrainCar?.ID}, {NetId}], coupler is front: {packet.IsFrontCoupler}, flags: {flags} restorestate: {coupler.state}, current state: {coupler.state}, Chain state:{coupler.ChainScript.state}, isCoupled: {coupler.IsCoupled()}");
+
+ if (coupler.ChainScript.state != ChainCouplerInteraction.State.Attached_Tight)
+ StartCoroutine(DangleCoupler(coupler));
+ else
+ Multiplayer.LogWarning(() => $"Received Dangle interaction for [{TrainCar?.ID}, {NetId}], coupler is front: {packet.IsFrontCoupler}, but coupler is in the wrong state: {coupler.state}, Chain state:{coupler.ChainScript.state}, isCoupled: {coupler.IsCoupled()}");
+ }
+
+ if (flags.HasFlag(CouplerInteractionType.CouplerLoosen))
+ {
+ Multiplayer.LogDebug(() => $"6 Common_ReceiveCouplerInteraction() [{TrainCar?.ID}, {NetId}], flags: {flags} current state: {coupler.ChainScript.state}");
+ if (coupler.ChainScript.state == ChainCouplerInteraction.State.Attached_Tight)
+ {
+ Multiplayer.LogDebug(() => $"7 Common_ReceiveCouplerInteraction() [{TrainCar?.ID}, {NetId}], coupler is front: {packet.IsFrontCoupler}, flags: {flags}");
+ coupler.ChainScript.fsm.Fire(ChainCouplerInteraction.Trigger.Screw_Used);
+ }
+ else if(coupler.ChainScript.CurrentState == ChainCouplerInteraction.State.Disabled && coupler.state == ChainCouplerInteraction.State.Attached_Tight)
+ {
+ //if it's disabled we'll use the internal routines and the state will restore when this player sees the coupling next
+ coupler.SetChainTight(false);
+ }
+ }
+
+ if (flags.HasFlag(CouplerInteractionType.CouplerTighten))
+ {
+ Multiplayer.LogDebug(() => $"8 Common_ReceiveCouplerInteraction() [{TrainCar?.ID}, {NetId}], flags: {flags} current state: {coupler.ChainScript.state}");
+ if (coupler.ChainScript.state == ChainCouplerInteraction.State.Attached_Loose)
+ {
+ Multiplayer.LogDebug(() => $"9 Common_ReceiveCouplerInteraction() [{TrainCar?.ID}, {NetId}], coupler is front: {packet.IsFrontCoupler}, flags: {flags}");
+ coupler.ChainScript.fsm.Fire(ChainCouplerInteraction.Trigger.Screw_Used);
+ }
+ else if (coupler.ChainScript.CurrentState == ChainCouplerInteraction.State.Disabled && coupler.state == ChainCouplerInteraction.State.Attached_Loose)
+ {
+ //if it's disabled we'll use the internal routines and the state will restore when this player sees the coupling next
+ coupler.SetChainTight(true);
+ }
+ }
+
+ if (flags.HasFlag(CouplerInteractionType.CoupleViaUI))
+ {
+ //if hose connect also requested, then we want everything to connect, otherwise only connect the chain
+ bool chainInteraction = !flags.HasFlag(CouplerInteractionType.HoseConnect);
+
+ Multiplayer.LogDebug(() => $"10 Common_ReceiveCouplerInteraction() [{TrainCar?.ID}, {NetId}], coupler is front: {packet.IsFrontCoupler}, flags: [{flags}], other coupler: {otherCoupler != null}, chainInteraction: {chainInteraction}");
+ if(otherCoupler != null)
+ {
+ Multiplayer.LogDebug(() => $"10A Common_ReceiveCouplerInteraction() [{TrainCar?.ID}, {NetId}], coupler state: {coupler.state}, other coupler state: {otherCoupler.state}, coupler coupledTo: {coupler?.coupledTo?.train?.ID}, other coupledTo: {otherCoupler?.coupledTo?.train?.ID}, chainInteraction: {chainInteraction}");
+ var car = coupler.CoupleTo(otherCoupler, viaChainInteraction: chainInteraction);
+
+ /* fix for bug in vanilla game */
+ coupler.SetChainTight(true);
+ if (coupler.ChainScript.enabled)
+ {
+ coupler.ChainScript.enabled = false;
+ coupler.ChainScript.enabled = true;
+ }
+ /* end fix for bug */
+
+ Multiplayer.LogDebug(() => $"10B Common_ReceiveCouplerInteraction() [{TrainCar?.ID}, {NetId}], result: {car != null}");
+ //todo: rework hose and MU interactions
+ }
+ }
+
+ if (flags.HasFlag(CouplerInteractionType.UncoupleViaUI))
+ {
+ //if hose connect also requested, then we want everything to disconnect, otherwise only disconnect the chain
+ bool chainInteraction = !flags.HasFlag(CouplerInteractionType.HoseDisconnect);
+
+ Multiplayer.LogDebug(() => $"11 Common_ReceiveCouplerInteraction() [{TrainCar?.ID}, {NetId}], coupler is front: {packet.IsFrontCoupler}, flags: {flags}, chainInteraction: {chainInteraction}");
+ CouplerLogic.Uncouple(coupler,viaChainInteraction: chainInteraction);
+
+ /* fix for bug in vanilla game */
+ coupler.state = ChainCouplerInteraction.State.Parked;
+ if (coupler.ChainScript.enabled)
+ {
+ coupler.ChainScript.enabled = false;
+ coupler.ChainScript.enabled = true;
+ }
+ /* end fix for bug */
+
+ //todo: rework hose and MU interactions
+ }
+
+ if (flags.HasFlag(CouplerInteractionType.CoupleViaRemote))
+ {
+ Multiplayer.LogDebug(() => $"12 Common_ReceiveCouplerInteraction() [{TrainCar?.ID}, {NetId}], coupler is front: {packet.IsFrontCoupler}, flags: {flags}, other coupler: {otherCoupler != null}");
+
+ if (TryGetComponent(out var couplingHandler))
+ couplingHandler.Couple();
+ }
+
+ if (flags.HasFlag(CouplerInteractionType.UncoupleViaRemote))
+ {
+ Multiplayer.LogDebug(() => $"13 Common_ReceiveCouplerInteraction() [{TrainCar?.ID}, {NetId}], coupler is front: {packet.IsFrontCoupler}, flags: {flags}");
+ if (coupler != null)
+ {
+ coupler.Uncouple(true, false, false, false);
+ MultipleUnitModule.DisconnectCablesIfMultipleUnitSupported(coupler.train, coupler.isFrontCoupler, !coupler.isFrontCoupler);
+ }
+ }
+
+ //presumably the interaction is now complete, release control to player
+ if (coupler?.ChainScript?.knobGizmo)
+ coupler.ChainScript.knobGizmo.InteractionAllowed = true;
+ if (coupler?.ChainScript?.screwButtonBase)
+ coupler.ChainScript.screwButtonBase.InteractionAllowed = true;
+ }
+
+ private IEnumerator LooseAttachCoupler(Coupler coupler, Coupler otherCoupler)
+ {
+ if (coupler == null || coupler.ChainScript == null ||
+ otherCoupler == null || otherCoupler.ChainScript == null ||
+ otherCoupler.ChainScript.ownAttachPoint == null)
+ {
+ Multiplayer.LogDebug(() => $"LooseAttachCoupler() [{TrainCar?.ID}], Null reference! Coupler: {coupler != null}, chainscript: {coupler?.ChainScript != null}, other coupler: {otherCoupler != null}, other chainscript: {otherCoupler?.ChainScript != null}, other attach point: {otherCoupler?.ChainScript?.ownAttachPoint}");
+ yield break;
+ }
+
+ ChainCouplerInteraction ccInteraction = coupler.ChainScript;
+
+ if(ccInteraction.CurrentState == ChainCouplerInteraction.State.Disabled)
+ {
+ //since it's disabled FSM events won't fire. Force a coupling if required, otherwise set state ready for player visibility trigger
+
+ if (coupler.coupledTo == null)
+ coupler.CoupleTo(otherCoupler, true, true);
+ else
+ coupler.state = ChainCouplerInteraction.State.Attached_Loose;
+
+ yield break;
+ }
+
+ //Simulate player pickup
+ coupler.ChainScript.fsm.Fire(ChainCouplerInteraction.Trigger.Picked_Up_By_Player);
+
+ //Set the knob position to the other coupler's hook
+ Vector3 targetHookPos = otherCoupler.ChainScript.ownAttachPoint.transform.position;
+ coupler.ChainScript.knob.transform.position = targetHookPos;
+
+ //allow the follower and IK solver to update
+ coupler.ChainScript.Update_Being_Dragged();
+
+ //we need to allow the IK solver to calculate the chain ring anchor's position over a number of iterations
+ int x = 0;
+ float distance = float.MaxValue;
+ //game checks for Vector3.Distance(this.chainRingAnchor.position, this.closestAttachPoint.transform.position) < attachDistanceThreshold;
+ while (distance >= ChainCouplerInteraction.attachDistanceThreshold && x < MAX_COUPLER_ITERATIONS)
+ {
+ distance = Vector3.Distance(ccInteraction.chainRingAnchor.position, targetHookPos);
+
+ x++;
+ yield return new WaitForSeconds(ccInteraction.ROTATION_SMOOTH_DURATION);
+ }
+
+ //Drop the chain
+ coupler.ChainScript.fsm.Fire(ChainCouplerInteraction.Trigger.Dropped_By_Player);
+ }
+
+ private IEnumerator ParkCoupler(Coupler coupler)
+ {
+ ChainCouplerInteraction ccInteraction = coupler.ChainScript;
+
+ if (ccInteraction.CurrentState == ChainCouplerInteraction.State.Disabled)
+ {
+ //since it's disabled FSM events won't fire, but state will be restored when the coupling is visible to the current player
+ if(coupler.state == ChainCouplerInteraction.State.Attached_Loose && coupler.coupledTo != null)
+ coupler.Uncouple(true, false, false, true);
+
+ coupler.state = ChainCouplerInteraction.State.Parked;
+
+ yield break;
+ }
+
+ //Simulate player pickup
+ coupler.ChainScript.fsm.Fire(ChainCouplerInteraction.Trigger.Picked_Up_By_Player);
+
+ //Set the knob position
+ Vector3 parkPos = coupler.ChainScript.parkedAnchor.position;
+
+ coupler.ChainScript.knob.transform.position = parkPos;
+
+ //allow the follower and IK solver to update
+ coupler.ChainScript.Update_Being_Dragged();
+
+ //we need to allow the IK solver to calculate the chain ring anchor's position over a number of iterations
+ int x = 0;
+ float distance = float.MaxValue;
+ //game checks for Vector3.Distance(this.chainRingAnchor.position, this.parkedAnchor.position) < parkDistanceThreshold;
+ //need to make sure we are closer than the threshold before dropping
+ while (distance > ChainCouplerInteraction.parkDistanceThreshold && x < MAX_COUPLER_ITERATIONS)
+ {
+ distance = Vector3.Distance(ccInteraction.chainRingAnchor.position, ccInteraction.parkedAnchor.position);
+
+ x++;
+ yield return new WaitForSeconds(ccInteraction.ROTATION_SMOOTH_DURATION);
+ }
+
+ //Drop the chain
+ coupler.ChainScript.fsm.Fire(ChainCouplerInteraction.Trigger.Dropped_By_Player);
+ }
+ private IEnumerator DangleCoupler(Coupler coupler)
+ {
+ ChainCouplerInteraction ccInteraction = coupler.ChainScript;
+
+ if (ccInteraction.CurrentState == ChainCouplerInteraction.State.Disabled)
+ {
+ //since it's disabled FSM events won't fire, but state will be restored when the coupling is visible to the current player
+ if (coupler.state == ChainCouplerInteraction.State.Attached_Loose && coupler.coupledTo != null)
+ coupler.Uncouple(true, false, false, true);
+
+ coupler.state = ChainCouplerInteraction.State.Dangling;
+
+ yield break;
+ }
+
+ //Simulate player pickup
+ coupler.ChainScript.fsm.Fire(ChainCouplerInteraction.Trigger.Picked_Up_By_Player);
+
+ Vector3 parkPos = coupler.ChainScript.parkedAnchor.position;
+
+ //Set the knob position
+ coupler.ChainScript.knob.transform.position = parkPos + Vector3.down; //ensure we are not near the park anchor or other car's anchor
+
+ //allow the follower and IK solver to update
+ coupler.ChainScript.Update_Being_Dragged();
+
+ //we need to allow the IK solver to calculate the chain ring anchor's position over a number of iterations
+ int x = 0;
+ float distance = float.MinValue;
+ //game checks for Vector3.Distance(this.chainRingAnchor.position, this.parkedAnchor.position) < parkDistanceThreshold;
+ //to determine if it should be parked or dangled, need to make sure we are at least at the threshold before dropping
+ while (distance <= ChainCouplerInteraction.parkDistanceThreshold && x < MAX_COUPLER_ITERATIONS)
+ {
+ distance = Vector3.Distance(ccInteraction.chainRingAnchor.position, ccInteraction.parkedAnchor.position);
+
+ x++;
+ yield return new WaitForSeconds(ccInteraction.ROTATION_SMOOTH_DURATION);
+ }
+
+ //Drop the chain
+ coupler.ChainScript.fsm.Fire(ChainCouplerInteraction.Trigger.Dropped_By_Player);
+ }
+
+ public void Common_ReceivePaintThemeUpdate(TrainCarPaint.Target target, PaintTheme paint)
+ {
+ TrainCarPaint targetPaint = null;
+
+ if (target == TrainCarPaint.Target.Interior)
+ {
+ Multiplayer.LogWarning($"Received Paint Theme update for [{CurrentID}, {NetId}], targeting Interior");
+ targetPaint = TrainCar.PaintInterior;
+ }
+ else if (target == TrainCarPaint.Target.Exterior)
+ {
+ Multiplayer.LogWarning($"Received Paint Theme update for [{CurrentID}, {NetId}], targeting Exterior");
+ targetPaint = TrainCar.PaintExterior;
+ }
+
+ if (targetPaint == null || !targetPaint.IsSupported(paint))
+ {
+ Multiplayer.LogWarning($"Received Paint Theme update for [{CurrentID}, {NetId}], but {paint?.assetName} is not supported");
+ return;
+ }
+
+ targetPaint.currentTheme = paint;
+ targetPaint.UpdateTheme();
+ TrainCar.OnPaintThemeChanged(targetPaint);
+ }
#endregion
#region Client
@@ -462,30 +1180,182 @@ private IEnumerator Client_InitLater()
yield return null;
while ((client_bogie2Queue = bogie2.GetComponent()) == null)
yield return null;
- client_Initialized = true;
+
+ Client_Initialized = true;
}
public void Client_ReceiveTrainPhysicsUpdate(in TrainsetMovementPart movementPart, uint tick)
{
- if (!client_Initialized)
+ if (!Client_Initialized)
return;
+
if (TrainCar.isEligibleForSleep)
TrainCar.ForceOptimizationState(false);
- if (movementPart.IsRigidbodySnapshot)
+ if (movementPart.typeFlag == TrainsetMovementPart.MovementType.RigidBody)
{
+ //Multiplayer.LogDebug(() => $"Client_ReceiveTrainPhysicsUpdate({TrainCar.ID}, {tick}): is RigidBody");
TrainCar.Derail();
TrainCar.stress.ResetTrainStress();
+ if (TrainCar.rb != null)
+ TrainCar.rb.constraints = RigidbodyConstraints.FreezeAll;
+
Client_trainRigidbodyQueue.ReceiveSnapshot(movementPart.RigidbodySnapshot, tick);
}
else
{
+ //move the car to the correct position first - maybe?
+ if (movementPart.typeFlag.HasFlag(TrainsetMovementPart.MovementType.Position))
+ {
+ TrainCar.transform.position = movementPart.Position + WorldMover.currentMove;
+ TrainCar.transform.rotation = movementPart.Rotation;
+
+ //clear the queues?
+ Client_trainSpeedQueue.Clear();
+ Client_trainRigidbodyQueue.Clear();
+ client_bogie1Queue.Clear();
+ client_bogie2Queue.Clear();
+
+ TrainCar.stress.ResetTrainStress();
+ }
+
Client_trainSpeedQueue.ReceiveSnapshot(movementPart.Speed, tick);
TrainCar.stress.slowBuildUpStress = movementPart.SlowBuildUpStress;
client_bogie1Queue.ReceiveSnapshot(movementPart.Bogie1, tick);
client_bogie2Queue.ReceiveSnapshot(movementPart.Bogie2, tick);
+
+
}
+
+ if (!TrainCar.derailed && TrainCar.rb != null)
+ TrainCar.rb.constraints = RigidbodyConstraints.None;
+ }
+
+ public void Client_ReceiveBrakeStateUpdate(ClientboundBrakeStateUpdatePacket packet)
+ {
+ if (brakeSystem == null)
+ return;
+
+ if (!hasSimFlow)
+ return;
+
+ brakeSystem.SetMainReservoirPressure(packet.MainReservoirPressure);
+
+ brakeSystem.brakePipePressure = packet.BrakePipePressure;
+ brakeSystem.brakeset.pipePressure = packet.BrakePipePressure;
+
+ brakeSystem.brakeCylinderPressure = packet.BrakeCylinderPressure;
+
+ if (brakeSystem.heatController == null)
+ return;
+
+ brakeSystem.heatController.overheatPercentage = packet.OverheatPercent;
+ brakeSystem.heatController.overheatReductionFactor = packet.OverheatReductionFactor;
+ brakeSystem.heatController.temperature = packet.Temperature;
}
+ private void Client_OnAddCoal(float coalMassDelta)
+ {
+ if (NetworkLifecycle.Instance.IsProcessingPacket)
+ return;
+
+ if (coalMassDelta <= 0)
+ return;
+
+ NetworkLifecycle.Instance.Client.LogDebug(() => $"Common_OnAddCoal({TrainCar.ID}): coalMassDelta: {coalMassDelta}");
+ NetworkLifecycle.Instance.Client.SendAddCoal(NetId, coalMassDelta);
+ }
+
+ private void Client_OnIgnite(float ignition)
+ {
+ if (NetworkLifecycle.Instance.IsProcessingPacket)
+ return;
+
+ if (ignition == 0f)
+ return;
+
+ NetworkLifecycle.Instance.Client.LogDebug(() => $"Common_OnIgnite({TrainCar.ID})");
+ NetworkLifecycle.Instance.Client.SendFireboxIgnition(NetId);
+ }
+
+ public void Client_ReceiveFireboxStateUpdate(float fireboxContents, bool isOn)
+ {
+ if (firebox == null)
+ return;
+
+ if (!hasSimFlow)
+ return;
+
+ firebox.fireboxContentsPort.Value = fireboxContents;
+ firebox.fireOnPort.Value = isOn ? 1f : 0f;
+ }
+
+ public void Client_CouplerStateChange(ChainCouplerInteraction.State state, Coupler coupler)
+ {
+ Multiplayer.LogDebug(() => $"1 Client_CouplerStateChange({state}) trainCar: [{TrainCar?.ID}, {NetId}], coupler is front: {coupler?.isFrontCoupler}");
+
+ //if we are processing a packet, then these state changes are likely triggered by a received update, not player interaction
+ //in future, maybe patch OnGrab() or add logic to add/remove action subscriptions
+ if (NetworkLifecycle.Instance.IsProcessingPacket)
+ return;
+
+ CouplerInteractionType interactionFlags = CouplerInteractionType.NoAction;
+ Coupler otherCoupler = null;
+
+ switch (state)
+ {
+ case ChainCouplerInteraction.State.Being_Dragged:
+ couplerInteraction = coupler;
+ originalState = coupler.state;
+ originalCoupledTo = coupler.coupledTo;
+ interactionFlags = CouplerInteractionType.Start;
+ Multiplayer.LogDebug(() => $"3 Client_CouplerStateChange({state}) trainCar: [{TrainCar?.ID}, {NetId}]");
+ break;
+
+ case ChainCouplerInteraction.State.Attached_Loose:
+ if (couplerInteraction != null)
+ {
+ //couldn't find an appropriate constant in the game code, other than the default value
+ //at B99.3 this distance is 1.5f for both default and constant/magic number
+ otherCoupler = coupler.GetFirstCouplerInRange();
+ Multiplayer.LogDebug(() => $"4 Client_CouplerStateChange({state}) trainCar: [{TrainCar?.ID}, {NetId}] coupledTo: {coupler?.coupledTo?.train?.ID}, first Coupler: {otherCoupler?.train?.ID}");
+ interactionFlags = CouplerInteractionType.CouplerCouple;
+ }
+ break;
+
+ case ChainCouplerInteraction.State.Parked:
+ if (couplerInteraction != null)
+ {
+ Multiplayer.LogDebug(() => $"6 Client_CouplerStateChange({state}) trainCar: [{TrainCar?.ID}, {NetId}]");
+ interactionFlags = CouplerInteractionType.CouplerPark;
+ }
+ break;
+
+ case ChainCouplerInteraction.State.Dangling:
+ if (couplerInteraction != null)
+ {
+ Multiplayer.LogDebug(() => $"7 Client_CouplerStateChange({state}) trainCar: [{TrainCar?.ID}, {NetId}]");
+ interactionFlags = CouplerInteractionType.CouplerDrop;
+ }
+ break;
+
+ default:
+ //nothing to do
+ break;
+ }
+
+ if (interactionFlags != CouplerInteractionType.NoAction)
+ {
+ Multiplayer.LogDebug(() => $"8 Client_CouplerStateChange({state}) trainCar: [{TrainCar?.ID}, {NetId}], coupler is front: {coupler?.isFrontCoupler}, Sending: {interactionFlags}");
+ NetworkLifecycle.Instance.Client.SendCouplerInteraction(interactionFlags, coupler, otherCoupler);
+
+ //finished interaction, clear flag
+ if (interactionFlags != CouplerInteractionType.Start)
+ couplerInteraction = null;
+
+ return;
+ }
+ Multiplayer.LogDebug(() => $"9 Client_CouplerStateChange({state}) trainCar: [{TrainCar?.ID}, {NetId}]");
+ }
#endregion
}
diff --git a/Multiplayer/Components/Networking/Train/PaintThemeLookup.cs b/Multiplayer/Components/Networking/Train/PaintThemeLookup.cs
new file mode 100644
index 0000000..1e8dd87
--- /dev/null
+++ b/Multiplayer/Components/Networking/Train/PaintThemeLookup.cs
@@ -0,0 +1,106 @@
+using DV.Customization.Paint;
+using DV.Utils;
+using System.Collections.Generic;
+using System.Linq;
+using UnityEngine;
+using JetBrains.Annotations;
+
+
+namespace Multiplayer.Components.Networking.Train;
+
+public class PaintThemeLookup : SingletonBehaviour
+{
+ private readonly Dictionary themeIndices = [];
+ private string[] themeNames;
+
+ protected override void Awake()
+ {
+ base.Awake();
+ themeNames = Resources.LoadAll