diff --git a/Makefile b/Makefile index f4de901..460f239 100644 --- a/Makefile +++ b/Makefile @@ -47,6 +47,12 @@ test-smoke: ## Run the end-to-end smoke test (tier 1) against the docker server check-assets: ## Verify presence and integrity of assets listed in manifest python3 ./script/check_assets.py --check-sizes -.PHONY: help up start stop down delete restart reset clean-cache log-asset log-db log-server test-smoke test-unit test-integration check-assets extract-models +cli: ## Run the CLI client in a container (connect, login, character select) + ./script/cli.sh $(ARGS) + +create-account: ## Create a new account with optional admin privileges (interactive or EMAIL=... PASSWORD=... ADMIN=true/false) + ./script/create-account.sh $(ARGS) + +.PHONY: help up start stop down delete restart reset clean-cache log-asset log-db log-server test-smoke test-unit test-integration check-assets extract-models cli create-account diff --git a/PerpetuumServer2.sln b/PerpetuumServer2.sln index b33a875..046928e 100644 --- a/PerpetuumServer2.sln +++ b/PerpetuumServer2.sln @@ -27,6 +27,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Perpetuum.Tests", "src\Perp EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Perpetuum.Tests.Integration", "src\Perpetuum.Tests.Integration\Perpetuum.Tests.Integration.csproj", "{ABE8E004-66D6-4D40-AEBF-3CFBD041E29B}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Perpetuum.CliClient", "src\Perpetuum.CliClient\Perpetuum.CliClient.csproj", "{B9D1A2E3-4F5C-6D7E-8F9A-0B1C2D3E4F5A}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|x64 = Debug|x64 @@ -77,6 +79,10 @@ Global {ABE8E004-66D6-4D40-AEBF-3CFBD041E29B}.Debug|x64.Build.0 = Debug|x64 {ABE8E004-66D6-4D40-AEBF-3CFBD041E29B}.Release|x64.ActiveCfg = Release|x64 {ABE8E004-66D6-4D40-AEBF-3CFBD041E29B}.Release|x64.Build.0 = Release|x64 + {B9D1A2E3-4F5C-6D7E-8F9A-0B1C2D3E4F5A}.Debug|x64.ActiveCfg = Debug|x64 + {B9D1A2E3-4F5C-6D7E-8F9A-0B1C2D3E4F5A}.Debug|x64.Build.0 = Debug|x64 + {B9D1A2E3-4F5C-6D7E-8F9A-0B1C2D3E4F5A}.Release|x64.ActiveCfg = Release|x64 + {B9D1A2E3-4F5C-6D7E-8F9A-0B1C2D3E4F5A}.Release|x64.Build.0 = Release|x64 EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -93,6 +99,7 @@ Global {A7D1E3C5-9F4B-42E8-8A6C-B5D7F1E9C2A0} = {0BC991A1-133C-49ED-A141-80E2A906898B} {C8C45427-6E5C-496C-9446-0817EADC05DD} = {0BC991A1-133C-49ED-A141-80E2A906898B} {ABE8E004-66D6-4D40-AEBF-3CFBD041E29B} = {0BC991A1-133C-49ED-A141-80E2A906898B} + {B9D1A2E3-4F5C-6D7E-8F9A-0B1C2D3E4F5A} = {0BC991A1-133C-49ED-A141-80E2A906898B} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {91874B60-30B0-4B48-9155-370E40BC70E6} diff --git a/script/cli.sh b/script/cli.sh new file mode 100755 index 0000000..159ac67 --- /dev/null +++ b/script/cli.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# Runs the Perpetuum Server CLI client inside the .NET SDK container +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" + +DOCKER_FLAGS="-i" +if [ -t 0 ] && [ -t 1 ]; then + DOCKER_FLAGS="-it" +fi + +CLI_DLL="/repo/src/Perpetuum.CliClient/bin/x64/Release/net8.0/Perpetuum.CliClient.dll" + +# Build if DLL does not exist +if [ ! -f "${REPO_ROOT}/src/Perpetuum.CliClient/bin/x64/Release/net8.0/Perpetuum.CliClient.dll" ]; then + echo "Building Perpetuum CLI client..." + docker run --rm -v "${REPO_ROOT}:/repo" -w /repo mcr.microsoft.com/dotnet/sdk:8.0 \ + dotnet build src/Perpetuum.CliClient/Perpetuum.CliClient.csproj -c Release -p:Platform=x64 +fi + +docker run ${DOCKER_FLAGS} --rm \ + --network host \ + -v "${REPO_ROOT}:/repo" \ + -w /repo \ + mcr.microsoft.com/dotnet/sdk:8.0 \ + dotnet "${CLI_DLL}" "$@" diff --git a/script/create-account.sh b/script/create-account.sh new file mode 100755 index 0000000..fe98712 --- /dev/null +++ b/script/create-account.sh @@ -0,0 +1,324 @@ +#!/usr/bin/env bash +# Creates a new account in Perpetuum Server database with optional admin access level. +set -eo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" + +COMPOSE_FILE="${REPO_ROOT}/compose.yml" +ENV_FILE="${REPO_ROOT}/.env.local" + +# Colors for terminal output +RED='\033[0;31m' +GREEN='\033[0;32m' +CYAN='\033[0;36m' +YELLOW='\033[1;33m' +BOLD='\033[1m' +NC='\033[0m' # No Color + +print_help() { + echo -e "${BOLD}Usage:${NC} $0 [options]" + echo "" + echo -e "${BOLD}Options:${NC}" + echo " -e, --email Account email or username (if omitted, will be prompted)" + echo " -p, --password Account password (if omitted, will be prompted securely)" + echo " -a, --admin Grant admin privileges (AccessLevel 14 / toolAdmin)" + echo " --no-admin Create standard player account (AccessLevel 2 / normal)" + echo " -h, --help Show this help message" + echo "" + echo -e "${BOLD}Environment variables:${NC}" + echo " EMAIL=" + echo " PASSWORD=" + echo " ADMIN=true|false|1|0" + echo "" + echo -e "${BOLD}Examples:${NC}" + echo " $0 # Interactive wizard (prompts for email, hidden password, admin)" + echo " $0 -e player1 # Prompts securely for password (hidden input)" + echo " $0 -e player1 -p secret123 # Standard player account" + echo " $0 -e gm_admin -p secret123 --admin # Admin account" + echo " make create-account EMAIL=gm ADMIN=true # Prompts securely for password" +} + +EMAIL_VAL="${EMAIL:-}" +PASSWORD_VAL="${PASSWORD:-${PASS:-}}" +ADMIN_VAL="" + +if [ -n "${ADMIN:-}" ]; then + case "${ADMIN,,}" in + true|1|yes|y) ADMIN_VAL="1" ;; + false|0|no|n) ADMIN_VAL="0" ;; + esac +fi + +EXPLICIT_EMAIL=0 +if [ -n "${EMAIL_VAL}" ]; then + EXPLICIT_EMAIL=1 +fi + +EXPLICIT_ADMIN=0 +if [ -n "${ADMIN_VAL}" ]; then + EXPLICIT_ADMIN=1 +fi + +# Parse CLI arguments +while [[ $# -gt 0 ]]; do + case "$1" in + -e|--email|--user|--username) + EMAIL_VAL="$2" + EXPLICIT_EMAIL=1 + shift 2 + ;; + -p|--password|--pass) + PASSWORD_VAL="$2" + shift 2 + ;; + -a|--admin) + ADMIN_VAL="1" + EXPLICIT_ADMIN=1 + shift + ;; + --no-admin) + ADMIN_VAL="0" + EXPLICIT_ADMIN=1 + shift + ;; + -h|--help) + print_help + exit 0 + ;; + *) + echo -e "${RED}Error: Unknown option '$1'${NC}" >&2 + echo "Use -h or --help for usage information." >&2 + exit 1 + ;; + esac +done + +# Helper function to prompt interactively even when invoked via make/subshell +read_prompt() { + local prompt_msg="$1" + local is_secret="${2:-0}" + local input="" + + if [ -t 0 ]; then + if [ "${is_secret}" -eq 1 ]; then + read -r -s -p "${prompt_msg}" input + echo "" >&2 + else + read -r -p "${prompt_msg}" input + fi + elif [ -r /dev/tty ]; then + if [ "${is_secret}" -eq 1 ]; then + read -r -s -p "${prompt_msg}" input &2 + else + read -r -p "${prompt_msg}" input &2 + done + else + echo -e "${RED}Error: Account email/username is required in non-interactive mode (-e or EMAIL=...)${NC}" >&2 + exit 1 + fi +fi + +# Trim whitespace from email +EMAIL_VAL="$(echo -n "${EMAIL_VAL}" | xargs)" + +if [ -z "${EMAIL_VAL}" ]; then + echo -e "${RED}Error: Account email/username cannot be empty.${NC}" >&2 + exit 1 +fi + +# Prompt securely for missing password (hiding characters) +if [ -z "${PASSWORD_VAL}" ]; then + if [ "${IS_INTERACTIVE}" -eq 1 ]; then + while true; do + PW_1=$(read_prompt "Enter password (hidden): " 1) + if [ -z "${PW_1}" ]; then + echo -e "${RED}Password cannot be empty. Please try again.${NC}" >&2 + continue + fi + + PW_2=$(read_prompt "Confirm password (hidden): " 1) + if [ "${PW_1}" != "${PW_2}" ]; then + echo -e "${RED}Passwords do not match. Please try again.${NC}" >&2 + continue + fi + + PASSWORD_VAL="${PW_1}" + break + done + else + echo -e "${RED}Error: Password is required in non-interactive mode (-p or PASSWORD=...)${NC}" >&2 + exit 1 + fi +fi + +if [ -z "${PASSWORD_VAL}" ]; then + echo -e "${RED}Error: Password cannot be empty.${NC}" >&2 + exit 1 +fi + +# Compute SHA-1 hash of password (ASCII encoding) to match Perpetuum authentication +if [[ ${#PASSWORD_VAL} -eq 40 && "${PASSWORD_VAL}" =~ ^[0-9a-fA-F]{40}$ ]]; then + PASSWORD_HASH="${PASSWORD_VAL^^}" +else + PASSWORD_HASH=$(echo -n "${PASSWORD_VAL}" | sha1sum | awk '{print toupper($1)}') +fi + +# Determine admin status +if [ -z "${ADMIN_VAL}" ]; then + if [ "${IS_INTERACTIVE}" -eq 1 ] && [ "${EXPLICIT_EMAIL}" -eq 0 ] && [ "${EXPLICIT_ADMIN}" -eq 0 ]; then + ADMIN_INPUT=$(read_prompt "Make this account an admin? [y/N]: " 0) + case "${ADMIN_INPUT,,}" in + y|yes|1|true) ADMIN_VAL="1" ;; + *) ADMIN_VAL="0" ;; + esac + else + ADMIN_VAL="0" + fi +fi + +if [ ! -f "${ENV_FILE}" ]; then + echo -e "${RED}Error: Environment file ${ENV_FILE} not found.${NC}" >&2 + exit 1 +fi + +compose() { + docker compose -f "${COMPOSE_FILE}" --env-file "${ENV_FILE}" "$@" +} + +# Ensure database container is running and healthy +db_status=$(compose ps -a --format '{{.State}}' db 2>/dev/null || echo "not_found") +if [ "${db_status}" != "running" ]; then + echo -e "${CYAN}Database container is not running. Starting database service...${NC}" + compose up -d db --wait +fi + +# Escape single quotes for SQL email +EMAIL_SQL="${EMAIL_VAL//\'/\'\'}" + +SQL_SCRIPT=$(cat <&1) + +# Check for database execution errors +if [ $? -ne 0 ]; then + echo -e "${RED}Error executing database query:${NC}" >&2 + echo "${OUTPUT}" >&2 + exit 1 +fi + +# Parse pipe-delimited result +RESULT_LINE=$(echo "${OUTPUT}" | grep -E '^[0-9-]+ *\|' | tail -n 1 || true) + +if [ -z "${RESULT_LINE}" ]; then + echo -e "${RED}Unexpected response from database:${NC}" >&2 + echo "${OUTPUT}" >&2 + exit 1 +fi + +ACCOUNT_ID=$(echo "${RESULT_LINE}" | awk -F'|' '{print $1}' | tr -d '[:space:]') +ACC_LEVEL=$(echo "${RESULT_LINE}" | awk -F'|' '{print $2}' | tr -d '[:space:]') +STATUS=$(echo "${RESULT_LINE}" | awk -F'|' '{print $3}' | tr -d '[:space:]') + +if [ "${STATUS}" = "EXISTS" ] || [ "${ACCOUNT_ID}" = "-1" ]; then + echo -e "${RED}Error: Account with email/username '${EMAIL_VAL}' already exists.${NC}" >&2 + exit 1 +fi + +if [ "${STATUS}" = "SUCCESS" ]; then + echo "" + echo -e "${GREEN}${BOLD}✓ Account created successfully!${NC}" + echo "----------------------------------------" + printf " %-18s: %s\n" "Account ID" "${ACCOUNT_ID}" + printf " %-18s: %s\n" "Username / Email" "${EMAIL_VAL}" + if [ "${ACC_LEVEL}" = "14" ]; then + printf " %-18s: %s\n" "Role" "Admin (Level 14 / toolAdmin)" + else + printf " %-18s: %s\n" "Role" "Player (Level 2 / normal)" + fi + printf " %-18s: %s\n" "Status" "Active (Email Confirmed)" + echo "----------------------------------------" + echo "" + exit 0 +fi + +echo -e "${RED}Failed to create account. Result: ${OUTPUT}${NC}" >&2 +exit 1 diff --git a/src/Perpetuum.CliClient/ChannelHistoryManager.cs b/src/Perpetuum.CliClient/ChannelHistoryManager.cs new file mode 100644 index 0000000..3a88cbb --- /dev/null +++ b/src/Perpetuum.CliClient/ChannelHistoryManager.cs @@ -0,0 +1,77 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using Perpetuum.CliClient.Models; + +namespace Perpetuum.CliClient +{ + public class ChannelHistoryManager + { + private readonly ConcurrentDictionary> _channelHistories = + new(StringComparer.OrdinalIgnoreCase); + + private readonly int _maxHistoryPerChannel; + + public ChannelHistoryManager(int maxHistoryPerChannel = 100) + { + _maxHistoryPerChannel = maxHistoryPerChannel; + } + + public void AddMessage(ChatMessage message) + { + if (string.IsNullOrEmpty(message.ChannelName)) + { + return; + } + + var list = _channelHistories.GetOrAdd(message.ChannelName, _ => new List()); + lock (list) + { + list.Add(message); + if (list.Count > _maxHistoryPerChannel) + { + list.RemoveAt(0); + } + } + } + + public IReadOnlyList GetRecentMessages(string channelName, int count = 5) + { + if (!_channelHistories.TryGetValue(channelName, out var list)) + { + return Array.Empty(); + } + + lock (list) + { + int skip = Math.Max(0, list.Count - count); + return list.Skip(skip).ToList(); + } + } + + public IReadOnlyList GetAllMessages(string channelName) + { + if (!_channelHistories.TryGetValue(channelName, out var list)) + { + return Array.Empty(); + } + + lock (list) + { + return list.ToList(); + } + } + + public void Clear(string channelName) + { + if (_channelHistories.TryGetValue(channelName, out var list)) + { + lock (list) + { + list.Clear(); + } + } + } + } +} diff --git a/src/Perpetuum.CliClient/ClientDictionaryExtensions.cs b/src/Perpetuum.CliClient/ClientDictionaryExtensions.cs new file mode 100644 index 0000000..48d1a7a --- /dev/null +++ b/src/Perpetuum.CliClient/ClientDictionaryExtensions.cs @@ -0,0 +1,144 @@ +using System; +using System.Collections.Generic; + +namespace Perpetuum.CliClient +{ + public static class ClientDictionaryExtensions + { + public static bool GetBool(this IDictionary? dict, string key, bool defaultValue = false) + { + if (dict == null || !dict.TryGetValue(key, out var val) || val == null) + { + return defaultValue; + } + + if (val is bool b) return b; + if (val is int i) return i != 0; + if (val is long l) return l != 0; + if (val is byte by) return by != 0; + if (val is short sh) return sh != 0; + if (val is string s) + { + if (bool.TryParse(s, out var sb)) return sb; + if (int.TryParse(s, out var si)) return si != 0; + } + + try + { + return Convert.ToBoolean(val); + } + catch + { + return defaultValue; + } + } + + public static int GetInt(this IDictionary? dict, string key, int defaultValue = 0) + { + if (dict == null || !dict.TryGetValue(key, out var val) || val == null) + { + return defaultValue; + } + + if (val is int i) return i; + if (val is long l) return (int)l; + if (val is short sh) return sh; + if (val is byte b) return b; + if (val is double d) return (int)d; + if (val is float f) return (int)f; + if (val is string s && int.TryParse(s, out var si)) return si; + + try + { + return Convert.ToInt32(val); + } + catch + { + return defaultValue; + } + } + + public static long GetLong(this IDictionary? dict, string key, long defaultValue = 0) + { + if (dict == null || !dict.TryGetValue(key, out var val) || val == null) + { + return defaultValue; + } + + if (val is long l) return l; + if (val is int i) return i; + if (val is short sh) return sh; + if (val is byte b) return b; + if (val is double d) return (long)d; + if (val is float f) return (long)f; + if (val is string s && long.TryParse(s, out var sl)) return sl; + + try + { + return Convert.ToInt64(val); + } + catch + { + return defaultValue; + } + } + + public static double GetDouble(this IDictionary? dict, string key, double defaultValue = 0.0) + { + if (dict == null || !dict.TryGetValue(key, out var val) || val == null) + { + return defaultValue; + } + + if (val is double d) return d; + if (val is float f) return f; + if (val is int i) return i; + if (val is long l) return l; + if (val is short sh) return sh; + if (val is byte b) return b; + if (val is string s && double.TryParse(s, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out var sd)) return sd; + + try + { + return Convert.ToDouble(val, System.Globalization.CultureInfo.InvariantCulture); + } + catch + { + return defaultValue; + } + } + + public static string GetString(this IDictionary? dict, string key, string defaultValue = "") + { + if (dict == null || !dict.TryGetValue(key, out var val) || val == null) + { + return defaultValue; + } + + return val.ToString() ?? defaultValue; + } + + public static DateTime? GetDateTime(this IDictionary? dict, string key) + { + if (dict == null || !dict.TryGetValue(key, out var val) || val == null) + { + return null; + } + + if (val is DateTime dt) return dt; + if (val is string s && DateTime.TryParse(s, out var dts)) return dts; + + return null; + } + + public static IDictionary? GetDictionary(this IDictionary? dict, string key) + { + if (dict == null || !dict.TryGetValue(key, out var val) || val == null) + { + return null; + } + + return val as IDictionary; + } + } +} diff --git a/src/Perpetuum.CliClient/CommandLineOptions.cs b/src/Perpetuum.CliClient/CommandLineOptions.cs new file mode 100644 index 0000000..285ae87 --- /dev/null +++ b/src/Perpetuum.CliClient/CommandLineOptions.cs @@ -0,0 +1,233 @@ +using System; + +namespace Perpetuum.CliClient +{ + public class CommandLineOptions + { + public string Host { get; set; } = "127.0.0.1"; + public int Port { get; set; } = 17700; + public string? Email { get; set; } + public string? Password { get; set; } + public string? PasswordHash { get; set; } + public string? Character { get; set; } + public string? Channel { get; set; } + public string? ChatMessage { get; set; } + public bool ListChannels { get; set; } + public bool ListStorage { get; set; } + public bool CreateAccount { get; set; } + public string? CreateCharacterNick { get; set; } + public int RaceId { get; set; } = 1; + public int SchoolId { get; set; } = 1; + public int MajorId { get; set; } = 1; + public int SparkId { get; set; } = 1; + public bool NonInteractive { get; set; } + public bool ShowHelp { get; set; } + + public static CommandLineOptions Parse(string[] args) + { + var options = new CommandLineOptions(); + + // Check environment variables first as defaults + var envHost = Environment.GetEnvironmentVariable("PERPETUUM_HOST"); + if (!string.IsNullOrWhiteSpace(envHost)) + { + options.Host = envHost; + } + + var envPort = Environment.GetEnvironmentVariable("PERPETUUM_PORT"); + if (!string.IsNullOrWhiteSpace(envPort) && int.TryParse(envPort, out var p)) + { + options.Port = p; + } + + var envEmail = Environment.GetEnvironmentVariable("PERPETUUM_EMAIL"); + if (!string.IsNullOrWhiteSpace(envEmail)) + { + options.Email = envEmail; + } + + var envPass = Environment.GetEnvironmentVariable("PERPETUUM_PASSWORD"); + if (!string.IsNullOrWhiteSpace(envPass)) + { + options.Password = envPass; + } + + for (int i = 0; i < args.Length; i++) + { + var arg = args[i]; + + switch (arg.ToLowerInvariant()) + { + case "--help": + case "-help": + case "-?": + case "/?": + options.ShowHelp = true; + break; + + case "--host": + case "-h": + if (i + 1 < args.Length) options.Host = args[++i]; + break; + + case "--port": + case "-p": + if (i + 1 < args.Length && int.TryParse(args[++i], out var portVal)) options.Port = portVal; + break; + + case "--email": + case "-e": + case "--user": + case "-u": + if (i + 1 < args.Length) options.Email = args[++i]; + break; + + case "--password": + case "--pass": + if (i + 1 < args.Length) options.Password = args[++i]; + break; + + case "--password-hash": + case "--hash": + if (i + 1 < args.Length) options.PasswordHash = args[++i]; + break; + + case "--character": + case "-c": + case "--char": + if (i + 1 < args.Length) options.Character = args[++i]; + break; + + case "--channel": + case "--chan": + if (i + 1 < args.Length) options.Channel = args[++i]; + break; + + case "--chat-msg": + case "--msg": + if (i + 1 < args.Length) options.ChatMessage = args[++i]; + break; + + case "--list-channels": + case "--channels": + options.ListChannels = true; + break; + + case "--storage": + case "--list-storage": + case "--hangar": + case "--inventory": + case "-s": + options.ListStorage = true; + break; + + case "--create-account": + case "--register": + options.CreateAccount = true; + break; + + case "--create-char": + case "--create-character": + case "-cc": + if (i + 1 < args.Length) options.CreateCharacterNick = args[++i]; + break; + + case "--race": + if (i + 1 < args.Length && int.TryParse(args[++i], out var raceVal)) options.RaceId = raceVal; + break; + + case "--school": + if (i + 1 < args.Length && int.TryParse(args[++i], out var schoolVal)) options.SchoolId = schoolVal; + break; + + case "--major": + if (i + 1 < args.Length && int.TryParse(args[++i], out var majorVal)) options.MajorId = majorVal; + break; + + case "--spark": + if (i + 1 < args.Length && int.TryParse(args[++i], out var sparkVal)) options.SparkId = sparkVal; + break; + + case "--non-interactive": + case "-n": + case "--batch": + options.NonInteractive = true; + break; + + default: + if (arg.StartsWith("--host=", StringComparison.OrdinalIgnoreCase)) + options.Host = arg.Substring(7); + else if (arg.StartsWith("--port=", StringComparison.OrdinalIgnoreCase) && int.TryParse(arg.Substring(7), out var pv)) + options.Port = pv; + else if (arg.StartsWith("--email=", StringComparison.OrdinalIgnoreCase)) + options.Email = arg.Substring(8); + else if (arg.StartsWith("--password=", StringComparison.OrdinalIgnoreCase)) + options.Password = arg.Substring(11); + else if (arg.StartsWith("--character=", StringComparison.OrdinalIgnoreCase)) + options.Character = arg.Substring(12); + else if (arg.StartsWith("--create-char=", StringComparison.OrdinalIgnoreCase)) + options.CreateCharacterNick = arg.Substring(14); + else if (arg.StartsWith("--create-character=", StringComparison.OrdinalIgnoreCase)) + options.CreateCharacterNick = arg.Substring(19); + else if (arg.StartsWith("--race=", StringComparison.OrdinalIgnoreCase) && int.TryParse(arg.Substring(7), out var rv)) + options.RaceId = rv; + else if (arg.StartsWith("--school=", StringComparison.OrdinalIgnoreCase) && int.TryParse(arg.Substring(9), out var sv)) + options.SchoolId = sv; + else if (arg.StartsWith("--major=", StringComparison.OrdinalIgnoreCase) && int.TryParse(arg.Substring(8), out var mv)) + options.MajorId = mv; + else if (arg.StartsWith("--spark=", StringComparison.OrdinalIgnoreCase) && int.TryParse(arg.Substring(8), out var spv)) + options.SparkId = spv; + else if (arg.StartsWith("--channel=", StringComparison.OrdinalIgnoreCase)) + options.Channel = arg.Substring(10); + else if (arg.StartsWith("--chat-msg=", StringComparison.OrdinalIgnoreCase)) + options.ChatMessage = arg.Substring(11); + break; + } + } + + return options; + } + + public static void PrintHelp() + { + Console.WriteLine(@"Perpetuum Server CLI Client + +Usage: + dotnet run --project src/Perpetuum.CliClient [options] + +Options: + -h, --host Server hostname or IP (default: 127.0.0.1 / PERPETUUM_HOST) + -p, --port Server port (default: 17700 / PERPETUUM_PORT) + -e, --email Account email / username (default: PERPETUUM_EMAIL) + --password Account plaintext password (default: PERPETUUM_PASSWORD) + --password-hash Account SHA-1 password hash (40-hex) + -c, --character Character ID or nickname to auto-select + --channel Chat channel to join/select + --chat-msg Chat message to send to the channel + --list-channels List available chat channels + -s, --storage List private station storage after character selection + --create-account Create a new account on the server + --create-char Create a new character with nickname + --race <1-3> Character race (1=Truxal, 2=Thelodica, 3=Nia-Korp; default: 1) + --school <1-3> Character school (1=Military, 2=Industrial, 3=Syndicate; default: 1) + --major <1-5> Character major (default: 1) + --spark <1-5> Character spark (default: 1) + -n, --non-interactive Run non-interactively (exit after character selection/message) + --help Show this help text + +Examples: + # Interactive mode + dotnet run --project src/Perpetuum.CliClient + + # Connect, create a character, and select it + dotnet run --project src/Perpetuum.CliClient -- -e pilot@example.com --password secret --create-char NewPilot + + # Connect, select character, and join shell + dotnet run --project src/Perpetuum.CliClient -- -h 127.0.0.1 -p 17700 -e pilot@example.com --password secret -c PilotName + + # Non-interactive / scripting: send a chat message + dotnet run --project src/Perpetuum.CliClient -- -e pilot@example.com --password secret -c PilotName --channel General --chat-msg ""Hello world!"" -n +"); + } + } +} diff --git a/src/Perpetuum.CliClient/ConsoleHelper.cs b/src/Perpetuum.CliClient/ConsoleHelper.cs new file mode 100644 index 0000000..0f0ee5f --- /dev/null +++ b/src/Perpetuum.CliClient/ConsoleHelper.cs @@ -0,0 +1,412 @@ +using System; +using System.Collections.Generic; +using Perpetuum.CliClient.Models; + +namespace Perpetuum.CliClient +{ + public static class ConsoleHelper + { + public static void WriteBanner() + { + Console.ForegroundColor = ConsoleColor.Cyan; + Console.WriteLine(@" + ___ ____ _____ _ _ ____ _____ ____ _____ _ _ _ _ __ __ + / _ \| _ \| ____| \ | | _ \| ____| _ \_ _| | | | | | | \/ | +| | | | |_) | _| | \| | |_) | _| | |_) || | | | | | | | | |\/| | +| |_| | __/| |___| |\ | __/| |___| _ < | | | |_| | |_| | | | | + \___/|_| |_____|_| \_|_| |_____|_| \_\|_| \___/ \___/|_| |_| + SERVER CLI CLIENT (OpenPerpetuum) +"); + Console.ResetColor(); + } + + public static void Info(string message) + { + Console.ForegroundColor = ConsoleColor.Cyan; + Console.Write("[INFO] "); + Console.ResetColor(); + Console.WriteLine(message); + } + + public static void Success(string message) + { + Console.ForegroundColor = ConsoleColor.Green; + Console.Write("[SUCCESS] "); + Console.ResetColor(); + Console.WriteLine(message); + } + + public static void Warn(string message) + { + Console.ForegroundColor = ConsoleColor.Yellow; + Console.Write("[WARN] "); + Console.ResetColor(); + Console.WriteLine(message); + } + + public static void Error(string message) + { + Console.ForegroundColor = ConsoleColor.Red; + Console.Write("[ERROR] "); + Console.ResetColor(); + Console.WriteLine(message); + } + + public static string ReadLine(string prompt, string? defaultValue = null) + { + Console.ForegroundColor = ConsoleColor.White; + if (!string.IsNullOrEmpty(defaultValue)) + { + Console.Write($"{prompt} [{defaultValue}]: "); + } + else + { + Console.Write($"{prompt}: "); + } + Console.ResetColor(); + + var input = Console.ReadLine(); + if (string.IsNullOrWhiteSpace(input) && defaultValue != null) + { + return defaultValue; + } + return input?.Trim() ?? string.Empty; + } + + public static string ReadPassword(string prompt) + { + Console.ForegroundColor = ConsoleColor.White; + Console.Write($"{prompt}: "); + Console.ResetColor(); + + var pass = new System.Text.StringBuilder(); + while (true) + { + var key = Console.ReadKey(intercept: true); + if (key.Key == ConsoleKey.Enter) + { + Console.WriteLine(); + break; + } + if (key.Key == ConsoleKey.Backspace) + { + if (pass.Length > 0) + { + pass.Remove(pass.Length - 1, 1); + Console.Write("\b \b"); + } + } + else if (!char.IsControl(key.KeyChar)) + { + pass.Append(key.KeyChar); + Console.Write("*"); + } + } + return pass.ToString(); + } + + public static void PrintWelcome(WelcomeInfo welcome, string endpoint) + { + Console.WriteLine(); + Console.ForegroundColor = ConsoleColor.DarkCyan; + Console.WriteLine("------------------- SERVER INFO -------------------"); + Console.ResetColor(); + Console.WriteLine($" Server: {welcome.WorldName} ({endpoint})"); + Console.WriteLine($" Version: {welcome.Version}"); + Console.WriteLine($" Server Time: {welcome.OSTime?.ToString("yyyy-MM-dd HH:mm:ss") ?? "N/A"}"); + if (!string.IsNullOrEmpty(welcome.ResourceServerUrl)) + Console.WriteLine($" Assets URL: {welcome.ResourceServerUrl}"); + Console.ForegroundColor = ConsoleColor.DarkCyan; + Console.WriteLine("---------------------------------------------------"); + Console.ResetColor(); + Console.WriteLine(); + } + + public static void PrintAccount(AccountInfo account) + { + Console.WriteLine(); + Console.ForegroundColor = ConsoleColor.DarkCyan; + Console.WriteLine("------------------ ACCOUNT INFO ------------------"); + Console.ResetColor(); + Console.WriteLine($" Account ID: {account.AccountId}"); + Console.WriteLine($" Email: {account.Email}"); + Console.WriteLine($" Access Level: {account.AccessLevel}"); + Console.WriteLine($" Credits: {account.Credit:N0} NIC"); + Console.WriteLine($" Subscriber: {(account.IsSubscriber ? "Yes" : "No")}"); + if (account.ValidUntil.HasValue) + Console.WriteLine($" Valid Until: {account.ValidUntil.Value:yyyy-MM-dd HH:mm:ss}"); + Console.ForegroundColor = ConsoleColor.DarkCyan; + Console.WriteLine("---------------------------------------------------"); + Console.ResetColor(); + Console.WriteLine(); + } + + public static void PrintCharactersTable(List characters, int extensionPoints) + { + Console.WriteLine(); + Console.ForegroundColor = ConsoleColor.DarkCyan; + Console.WriteLine($"---------------- AVAILABLE CHARACTERS (EP: {extensionPoints:N0}) ----------------"); + Console.ResetColor(); + + if (characters.Count == 0) + { + Console.ForegroundColor = ConsoleColor.Yellow; + Console.WriteLine(" No characters found on this account."); + Console.ResetColor(); + Console.ForegroundColor = ConsoleColor.DarkCyan; + Console.WriteLine("-------------------------------------------------------------------"); + Console.ResetColor(); + Console.WriteLine(); + return; + } + + Console.ForegroundColor = ConsoleColor.DarkGray; + Console.WriteLine(" # | ID | Nickname | Location / Base | Status | Credits (NIC)"); + Console.WriteLine("----+---------+-----------------+---------------------------+--------+--------------"); + Console.ResetColor(); + + for (int i = 0; i < characters.Count; i++) + { + var c = characters[i]; + var location = c.IsDocked + ? (!string.IsNullOrEmpty(c.BaseName) ? c.BaseName : $"Base #{c.BaseEid}") + : (c.ZoneId.HasValue ? $"Zone #{c.ZoneId}" : "In Space"); + + var status = c.IsDocked ? "Docked" : "In Zone"; + if (c.InUse) status += " (Online)"; + + Console.WriteLine($" {i + 1,2} | {c.CharacterId,7} | {c.Nick,-15} | {location,-25} | {status,-6} | {c.Credit,13:N0}"); + } + + Console.ForegroundColor = ConsoleColor.DarkCyan; + Console.WriteLine("-------------------------------------------------------------------"); + Console.ResetColor(); + Console.WriteLine(); + } + + public static void PrintSelectedCharacter(CharacterSelectResult result, string nick = "") + { + Console.WriteLine(); + Console.ForegroundColor = ConsoleColor.Green; + Console.WriteLine("================ CHARACTER SELECTED ================"); + Console.ResetColor(); + Console.WriteLine($" Nickname: {(string.IsNullOrEmpty(nick) ? "N/A" : nick)}"); + Console.WriteLine($" Character ID: {result.CharacterId}"); + Console.WriteLine($" Root EID: {result.RootEid}"); + Console.WriteLine($" Corporation EID: {result.CorporationEid}"); + if (result.AllianceEid > 0) + Console.WriteLine($" Alliance EID: {result.AllianceEid}"); + Console.WriteLine($" Status: {(result.IsDocked ? "Docked at Base" : "Active in Zone")}"); + if (result.ZoneData != null) + { + Console.ForegroundColor = ConsoleColor.DarkYellow; + Console.WriteLine(" Zone Details:"); + foreach (var kvp in result.ZoneData) + { + Console.WriteLine($" - {kvp.Key}"); + } + Console.ResetColor(); + } + Console.ForegroundColor = ConsoleColor.Green; + Console.WriteLine("===================================================="); + Console.ResetColor(); + Console.WriteLine(); + } + + public static void PrintChannelsTable(List channels, string title = "AVAILABLE CHANNELS") + { + Console.WriteLine(); + Console.ForegroundColor = ConsoleColor.DarkCyan; + Console.WriteLine($"---------------- {title} ({channels.Count}) ----------------"); + Console.ResetColor(); + + if (channels.Count == 0) + { + Console.ForegroundColor = ConsoleColor.Yellow; + Console.WriteLine(" No channels found."); + Console.ResetColor(); + Console.ForegroundColor = ConsoleColor.DarkCyan; + Console.WriteLine("-------------------------------------------------------------------"); + Console.ResetColor(); + Console.WriteLine(); + return; + } + + Console.ForegroundColor = ConsoleColor.DarkGray; + Console.WriteLine(" # | Channel Name | Type | Users | Topic"); + Console.WriteLine("----+--------------------------+--------------+-------+-----------------------------"); + Console.ResetColor(); + + for (int i = 0; i < channels.Count; i++) + { + var c = channels[i]; + var pw = c.HasPassword ? " [PW]" : ""; + var topic = string.IsNullOrEmpty(c.Topic) ? "-" : (c.Topic.Length > 30 ? c.Topic[..27] + "..." : c.Topic); + Console.WriteLine($" {i + 1,2} | {c.Name + pw,-24} | {c.TypeName,-12} | {c.MemberCount,5} | {topic}"); + } + + Console.ForegroundColor = ConsoleColor.DarkCyan; + Console.WriteLine("-------------------------------------------------------------------"); + Console.ResetColor(); + Console.WriteLine(); + } + + public static void PrintChatMessage(ChatMessage chat) + { + Console.ForegroundColor = ConsoleColor.DarkGray; + Console.Write($"[{chat.Timestamp:HH:mm:ss}] "); + Console.ForegroundColor = ConsoleColor.Magenta; + Console.Write($"[#{chat.ChannelName}] "); + Console.ForegroundColor = ConsoleColor.Yellow; + Console.Write($"<{chat.SenderId}>: "); + Console.ResetColor(); + Console.WriteLine(chat.Message); + } + + public static void PrintChannelHistoryBox(string channelName, string? topic, int memberCount, IReadOnlyList recentMessages, int visibleLines = 5) + { + var width = 78; + try + { + if (Console.WindowWidth > 20) + { + width = Math.Clamp(Console.WindowWidth - 2, 50, 95); + } + } + catch { } + + Console.WriteLine(); + Console.ForegroundColor = ConsoleColor.Cyan; + var headerTitle = $" # {channelName} "; + var topicInfo = !string.IsNullOrEmpty(topic) ? $" - Topic: {topic}" : ""; + var headerRight = memberCount > 0 ? $" [{memberCount} online] " : " "; + var headerText = $"───{headerTitle}{topicInfo}"; + if (headerText.Length + headerRight.Length >= width) + { + headerText = headerText.Substring(0, Math.Max(10, width - headerRight.Length - 4)) + "..."; + } + var remainingDashes = Math.Max(0, width - headerText.Length - headerRight.Length - 2); + + Console.WriteLine($"┌{headerText}{new string('─', remainingDashes)}{headerRight}┐"); + Console.ResetColor(); + + if (recentMessages.Count == 0) + { + Console.ForegroundColor = ConsoleColor.DarkGray; + var emptyMsg = $"│ (no recent messages in #{channelName})"; + var padEmpty = Math.Max(0, width - emptyMsg.Length - 1); + Console.WriteLine($"{emptyMsg}{new string(' ', padEmpty)}│"); + Console.ResetColor(); + } + else + { + var displayList = recentMessages.TakeLast(visibleLines).ToList(); + foreach (var msg in displayList) + { + var timeStr = $"[{msg.Timestamp:HH:mm:ss}]"; + var senderStr = $"<{msg.SenderId}>:"; + var text = msg.Message ?? string.Empty; + + Console.ForegroundColor = ConsoleColor.DarkGray; + Console.Write("│ "); + Console.ForegroundColor = ConsoleColor.DarkCyan; + Console.Write($"{timeStr} "); + Console.ForegroundColor = ConsoleColor.Yellow; + Console.Write($"{senderStr} "); + Console.ResetColor(); + + var prefixLen = 2 + timeStr.Length + 1 + senderStr.Length + 1; + var availableTextLen = width - prefixLen - 2; + if (availableTextLen > 0 && text.Length > availableTextLen) + { + text = text.Substring(0, availableTextLen - 3) + "..."; + } + + Console.Write(text); + var pad = Math.Max(0, width - prefixLen - text.Length - 1); + Console.WriteLine(new string(' ', pad) + "│"); + } + } + + Console.ForegroundColor = ConsoleColor.Cyan; + Console.WriteLine($"└{new string('─', width - 2)}┘"); + Console.ResetColor(); + } + + public static void PrintStorageTable(StorageResult storage, string characterNick = "") + { + Console.WriteLine(); + Console.ForegroundColor = ConsoleColor.DarkCyan; + Console.WriteLine("------------------- STATION PRIVATE STORAGE -------------------"); + Console.ResetColor(); + + if (!string.IsNullOrWhiteSpace(characterNick)) + { + Console.WriteLine($" Character: {characterNick}"); + } + + var baseLabel = !string.IsNullOrWhiteSpace(storage.BaseName) + ? $"{storage.BaseName} (#{storage.BaseEid})" + : (storage.BaseEid > 0 ? $"Base #{storage.BaseEid}" : "N/A"); + Console.WriteLine($" Location: {baseLabel}"); + Console.WriteLine($" Container: #{storage.ContainerEid} (Items: {storage.TotalItemCount:N0}, Total Volume: {storage.TotalVolume:N2} m³)"); + + Console.ForegroundColor = ConsoleColor.DarkCyan; + Console.WriteLine("---------------------------------------------------------------"); + Console.ResetColor(); + + if (storage.Items.Count == 0) + { + Console.ForegroundColor = ConsoleColor.Yellow; + Console.WriteLine(" No items found in private storage at this station."); + Console.ResetColor(); + Console.ForegroundColor = ConsoleColor.DarkCyan; + Console.WriteLine("---------------------------------------------------------------"); + Console.ResetColor(); + Console.WriteLine(); + return; + } + + Console.ForegroundColor = ConsoleColor.DarkGray; + Console.WriteLine(" # | Item Name / Type | Qty | Pack | Health | Volume (m³) | Item EID"); + Console.WriteLine("----+-------------------------------+---------+------+--------+-------------+----------------"); + Console.ResetColor(); + + for (int i = 0; i < storage.Items.Count; i++) + { + var item = storage.Items[i]; + PrintStorageItemRow(i + 1, item, indent: 0); + } + + Console.ForegroundColor = ConsoleColor.DarkCyan; + Console.WriteLine("---------------------------------------------------------------"); + Console.ResetColor(); + Console.WriteLine(); + } + + private static void PrintStorageItemRow(int index, StorageItemInfo item, int indent) + { + var prefix = indent > 0 ? new string(' ', indent * 2) + "└── " : ""; + var name = prefix + item.DisplayName; + if (name.Length > 29) + { + name = name.Substring(0, 26) + "..."; + } + + var idxStr = indent == 0 ? $"{index,2}" : " "; + var packStr = item.IsRepackaged ? "Yes" : "No"; + var healthStr = $"{item.Health:F0}%"; + var volStr = $"{item.Volume:F2}"; + + Console.WriteLine($" {idxStr} | {name,-29} | {item.Quantity,7:N0} | {packStr,-4} | {healthStr,6} | {volStr,11} | {item.Eid}"); + + if (item.Items.Count > 0) + { + for (int j = 0; j < item.Items.Count; j++) + { + PrintStorageItemRow(j + 1, item.Items[j], indent + 1); + } + } + } + } +} diff --git a/src/Perpetuum.CliClient/Models/AccountInfo.cs b/src/Perpetuum.CliClient/Models/AccountInfo.cs new file mode 100644 index 0000000..b2c07e1 --- /dev/null +++ b/src/Perpetuum.CliClient/Models/AccountInfo.cs @@ -0,0 +1,44 @@ +using System; +using System.Collections.Generic; + +namespace Perpetuum.CliClient.Models +{ + public class AccountInfo + { + public int AccountId { get; init; } + public string Email { get; init; } = string.Empty; + public AccessLevel AccessLevel { get; init; } = AccessLevel.normal; + public int Credit { get; init; } + public bool IsSubscriber { get; init; } + public bool EmailConfirmed { get; init; } + public DateTime? ValidUntil { get; init; } + public IReadOnlyDictionary RawData { get; init; } = new Dictionary(); + + public static AccountInfo FromDictionary(IDictionary data) + { + var accountId = data.GetInt(k.accountID); + if (accountId == 0) + { + accountId = data.GetInt("id"); + } + + var accessLevelInt = data.GetInt(k.accessLevel); + if (accessLevelInt == 0) + { + accessLevelInt = data.GetInt(k.accLevel); + } + + return new AccountInfo + { + AccountId = accountId, + Email = data.GetString(k.email), + AccessLevel = (AccessLevel)accessLevelInt, + Credit = data.GetInt(k.credit), + IsSubscriber = data.GetBool(k.isSubscriber), + EmailConfirmed = data.GetBool(k.emailConfirmed), + ValidUntil = data.GetDateTime(k.validUntil), + RawData = (data as IReadOnlyDictionary) ?? new Dictionary(data) + }; + } + } +} diff --git a/src/Perpetuum.CliClient/Models/ChannelInfo.cs b/src/Perpetuum.CliClient/Models/ChannelInfo.cs new file mode 100644 index 0000000..8404787 --- /dev/null +++ b/src/Perpetuum.CliClient/Models/ChannelInfo.cs @@ -0,0 +1,38 @@ +using System.Collections.Generic; + +namespace Perpetuum.CliClient.Models +{ + public class ChannelInfo + { + public string Name { get; init; } = string.Empty; + public int Type { get; init; } + public bool HasPassword { get; init; } + public int MemberCount { get; init; } + public string Topic { get; init; } = string.Empty; + public IReadOnlyDictionary RawData { get; init; } = new Dictionary(); + + public string TypeName => Type switch + { + 0 => "Public", + 1 => "Highlighted", + 2 => "Corporation", + 3 => "Gang", + 4 => "Station", + 5 => "Admin", + _ => $"Type({Type})" + }; + + public static ChannelInfo FromDictionary(IDictionary data) + { + return new ChannelInfo + { + Name = data.GetString(k.name), + Type = data.GetInt(k.type), + HasPassword = data.GetBool(k.password), + MemberCount = data.GetInt(k.count), + Topic = data.GetString(k.topic), + RawData = (data as IReadOnlyDictionary) ?? new Dictionary(data) + }; + } + } +} diff --git a/src/Perpetuum.CliClient/Models/CharacterListResult.cs b/src/Perpetuum.CliClient/Models/CharacterListResult.cs new file mode 100644 index 0000000..ad00f8c --- /dev/null +++ b/src/Perpetuum.CliClient/Models/CharacterListResult.cs @@ -0,0 +1,43 @@ +using System.Collections.Generic; + +namespace Perpetuum.CliClient.Models +{ + public class CharacterListResult + { + public List Characters { get; init; } = new List(); + public int ExtensionPoints { get; init; } + public IReadOnlyDictionary RawData { get; init; } = new Dictionary(); + + public static CharacterListResult FromDictionary(IDictionary data) + { + var result = new CharacterListResult(); + IDictionary? content = data; + + // In CharacterList handler, data is wrapped in "result": { characters: { ... }, extensionPoints: ... } + if (data.TryGetValue(k.result, out var resObj) && resObj is IDictionary resDict) + { + content = resDict; + } + + var extensionPoints = content.GetInt("extensionPoints"); + + if (content.TryGetValue("characters", out var charObj) && charObj is IDictionary charDict) + { + foreach (var kvp in charDict) + { + if (kvp.Value is IDictionary cdict) + { + result.Characters.Add(CharacterSummary.FromDictionary(cdict)); + } + } + } + + return new CharacterListResult + { + Characters = result.Characters, + ExtensionPoints = extensionPoints, + RawData = (data as IReadOnlyDictionary) ?? new Dictionary(data) + }; + } + } +} diff --git a/src/Perpetuum.CliClient/Models/CharacterSelectResult.cs b/src/Perpetuum.CliClient/Models/CharacterSelectResult.cs new file mode 100644 index 0000000..67f4394 --- /dev/null +++ b/src/Perpetuum.CliClient/Models/CharacterSelectResult.cs @@ -0,0 +1,43 @@ +using System; +using System.Collections.Generic; + +namespace Perpetuum.CliClient.Models +{ + public class CharacterSelectResult + { + public int CharacterId { get; init; } + public long RootEid { get; init; } + public long CorporationEid { get; init; } + public long AllianceEid { get; init; } + public bool IsDocked { get; init; } + public long BaseEid { get; set; } + public string BaseName { get; set; } = string.Empty; + public IDictionary? ZoneData { get; init; } + public IReadOnlyDictionary RawData { get; init; } = new Dictionary(); + + public static CharacterSelectResult FromDictionary(IDictionary data) + { + var charId = data.GetInt(k.characterID); + var rootEid = data.GetLong(k.rootEID); + var corpEid = data.GetLong(k.corporationEID); + var allianceEid = data.GetLong(k.allianceEID); + var baseEid = data.GetLong(k.baseEID); + var baseName = data.GetString(k.baseName); + + IDictionary? zoneDict = data.GetDictionary(k.zone); + + return new CharacterSelectResult + { + CharacterId = charId, + RootEid = rootEid, + CorporationEid = corpEid, + AllianceEid = allianceEid, + IsDocked = zoneDict == null, + BaseEid = baseEid, + BaseName = baseName, + ZoneData = zoneDict, + RawData = (data as IReadOnlyDictionary) ?? new Dictionary(data) + }; + } + } +} diff --git a/src/Perpetuum.CliClient/Models/CharacterSummary.cs b/src/Perpetuum.CliClient/Models/CharacterSummary.cs new file mode 100644 index 0000000..6e8a03e --- /dev/null +++ b/src/Perpetuum.CliClient/Models/CharacterSummary.cs @@ -0,0 +1,54 @@ +using System; +using System.Collections.Generic; + +namespace Perpetuum.CliClient.Models +{ + public class CharacterSummary + { + public int CharacterId { get; init; } + public long RootEid { get; init; } + public string Nick { get; init; } = string.Empty; + public bool IsDocked { get; init; } + public long BaseEid { get; init; } + public string BaseName { get; init; } = string.Empty; + public long HomeBaseEid { get; init; } + public string HomeBaseName { get; init; } = string.Empty; + public int? ZoneId { get; init; } + public long Credit { get; init; } + public DateTime? Creation { get; init; } + public DateTime? LastUsed { get; init; } + public string MoodMessage { get; init; } = string.Empty; + public bool InUse { get; init; } + public bool IsOffensiveNick { get; init; } + public IReadOnlyDictionary RawData { get; init; } = new Dictionary(); + + public static CharacterSummary FromDictionary(IDictionary dict) + { + int? zoneId = null; + if (dict.TryGetValue(k.zoneID, out var zObj) && zObj != null) + { + zoneId = dict.GetInt(k.zoneID); + } + + return new CharacterSummary + { + CharacterId = dict.GetInt(k.characterID), + RootEid = dict.GetLong(k.rootEID), + Nick = dict.GetString(k.nick), + IsDocked = dict.GetBool(k.docked), + BaseEid = dict.GetLong(k.baseEID), + BaseName = dict.GetString(k.baseName), + HomeBaseEid = dict.GetLong(k.homeBaseEID), + HomeBaseName = dict.GetString(k.homeBaseName), + ZoneId = zoneId, + Credit = dict.GetLong(k.credit), + Creation = dict.GetDateTime(k.creation), + LastUsed = dict.GetDateTime(k.lastUsed), + MoodMessage = dict.GetString(k.moodMessage), + InUse = dict.GetBool(k.inUse), + IsOffensiveNick = dict.GetBool(k.offensiveNick), + RawData = (dict as IReadOnlyDictionary) ?? new Dictionary(dict) + }; + } + } +} diff --git a/src/Perpetuum.CliClient/Models/ChatMessage.cs b/src/Perpetuum.CliClient/Models/ChatMessage.cs new file mode 100644 index 0000000..b8550a6 --- /dev/null +++ b/src/Perpetuum.CliClient/Models/ChatMessage.cs @@ -0,0 +1,44 @@ +using System; +using System.Collections.Generic; + +namespace Perpetuum.CliClient.Models +{ + public class ChatMessage + { + public string ChannelName { get; init; } = string.Empty; + public int SenderId { get; init; } + public string Message { get; init; } = string.Empty; + public DateTime Timestamp { get; init; } = DateTime.UtcNow; + public IReadOnlyDictionary RawData { get; init; } = new Dictionary(); + + public static ChatMessage? FromNotification(IDictionary notificationData) + { + var channelName = notificationData.GetString(k.channel); + var command = notificationData.GetInt(k.command); + + // ChannelNotify.Message is 6 + if (command != 6) + { + return null; + } + + var innerData = notificationData.GetDictionary(k.data); + if (innerData == null) + { + return null; + } + + var senderId = innerData.GetInt(k.sender); + var message = innerData.GetString(k.message); + + return new ChatMessage + { + ChannelName = channelName, + SenderId = senderId, + Message = message, + Timestamp = DateTime.UtcNow, + RawData = (notificationData as IReadOnlyDictionary) ?? new Dictionary(notificationData) + }; + } + } +} diff --git a/src/Perpetuum.CliClient/Models/StorageItemInfo.cs b/src/Perpetuum.CliClient/Models/StorageItemInfo.cs new file mode 100644 index 0000000..59fbddc --- /dev/null +++ b/src/Perpetuum.CliClient/Models/StorageItemInfo.cs @@ -0,0 +1,103 @@ +using System; +using System.Collections.Generic; + +namespace Perpetuum.CliClient.Models +{ + public class StorageItemInfo + { + public long Eid { get; init; } + public int Definition { get; init; } + public string DefinitionName { get; set; } = string.Empty; + public string Name { get; init; } = string.Empty; + public int Quantity { get; init; } + public bool IsRepackaged { get; init; } + public double Health { get; init; } + public double Volume { get; init; } + public long Parent { get; init; } + public long Owner { get; init; } + public List Items { get; init; } = new(); + public IReadOnlyDictionary RawData { get; init; } = new Dictionary(); + + public string DisplayName => !string.IsNullOrWhiteSpace(Name) + ? Name + : (!string.IsNullOrWhiteSpace(DefinitionName) ? FormatDefinitionName(DefinitionName) : $"Item #{Definition}"); + + public static string FormatDefinitionName(string raw) + { + if (string.IsNullOrWhiteSpace(raw)) return string.Empty; + var text = raw; + if (text.StartsWith("def_", StringComparison.OrdinalIgnoreCase)) + { + text = text.Substring(4); + } + + var parts = text.Split('_', StringSplitOptions.RemoveEmptyEntries); + for (int i = 0; i < parts.Length; i++) + { + if (parts[i].Length > 0) + { + parts[i] = char.ToUpperInvariant(parts[i][0]) + (parts[i].Length > 1 ? parts[i].Substring(1) : ""); + } + } + return string.Join(" ", parts); + } + + public static StorageItemInfo FromDictionary(IDictionary dict, Dictionary? definitionNames = null) + { + var defId = dict.GetInt(k.definition); + string defName = string.Empty; + if (definitionNames != null && definitionNames.TryGetValue(defId, out var dn)) + { + defName = dn; + } + else if (dict.TryGetValue(k.definitionName, out var dnObj) && dnObj != null) + { + defName = dnObj.ToString() ?? string.Empty; + } + + var subItems = new List(); + if (dict.TryGetValue(k.items, out var itemsObj) && itemsObj != null) + { + if (itemsObj is IDictionary itemsDict) + { + foreach (var kvp in itemsDict) + { + if (kvp.Value is IDictionary childDict) + { + subItems.Add(FromDictionary(childDict, definitionNames)); + } + } + } + else if (itemsObj is IEnumerable itemsEnum) + { + foreach (var item in itemsEnum) + { + if (item is IDictionary childDict) + { + subItems.Add(FromDictionary(childDict, definitionNames)); + } + } + } + } + + var quantity = dict.GetInt(k.quantity, 1); + if (quantity <= 0) quantity = 1; + + return new StorageItemInfo + { + Eid = dict.GetLong(k.eid), + Definition = defId, + DefinitionName = defName, + Name = dict.GetString(k.name), + Quantity = quantity, + IsRepackaged = dict.GetBool(k.repackaged), + Health = dict.GetDouble(k.health, 100.0), + Volume = dict.GetDouble(k.volume), + Parent = dict.GetLong(k.parent), + Owner = dict.GetLong(k.owner), + Items = subItems, + RawData = (dict as IReadOnlyDictionary) ?? new Dictionary(dict) + }; + } + } +} diff --git a/src/Perpetuum.CliClient/Models/StorageResult.cs b/src/Perpetuum.CliClient/Models/StorageResult.cs new file mode 100644 index 0000000..6db3acd --- /dev/null +++ b/src/Perpetuum.CliClient/Models/StorageResult.cs @@ -0,0 +1,56 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Perpetuum.CliClient.Models +{ + public class StorageResult + { + public long ContainerEid { get; init; } + public int ContainerDefinition { get; init; } + public long BaseEid { get; init; } + public string BaseName { get; set; } = string.Empty; + public List Items { get; init; } = new(); + public IReadOnlyDictionary RawData { get; init; } = new Dictionary(); + + public int TotalItemCount => Items.Sum(i => i.Quantity > 0 ? i.Quantity : 1); + public double TotalVolume => Items.Sum(i => i.Volume); + + public static StorageResult FromDictionary(IDictionary dict, Dictionary? definitionNames = null) + { + var itemsList = new List(); + if (dict.TryGetValue(k.items, out var itemsObj) && itemsObj != null) + { + if (itemsObj is IDictionary itemsDict) + { + foreach (var kvp in itemsDict) + { + if (kvp.Value is IDictionary itemDict) + { + itemsList.Add(StorageItemInfo.FromDictionary(itemDict, definitionNames)); + } + } + } + else if (itemsObj is IEnumerable itemsEnum) + { + foreach (var item in itemsEnum) + { + if (item is IDictionary itemDict) + { + itemsList.Add(StorageItemInfo.FromDictionary(itemDict, definitionNames)); + } + } + } + } + + return new StorageResult + { + ContainerEid = dict.GetLong(k.eid), + ContainerDefinition = dict.GetInt(k.definition), + BaseEid = dict.GetLong(k.baseEID), + Items = itemsList, + RawData = (dict as IReadOnlyDictionary) ?? new Dictionary(dict) + }; + } + } +} diff --git a/src/Perpetuum.CliClient/Models/WelcomeInfo.cs b/src/Perpetuum.CliClient/Models/WelcomeInfo.cs new file mode 100644 index 0000000..c5c0b27 --- /dev/null +++ b/src/Perpetuum.CliClient/Models/WelcomeInfo.cs @@ -0,0 +1,30 @@ +using System; +using System.Collections.Generic; + +namespace Perpetuum.CliClient.Models +{ + public class WelcomeInfo + { + public string WorldName { get; init; } = string.Empty; + public string Version { get; init; } = string.Empty; + public DateTime? OSTime { get; init; } + public bool SteamLoginEnabled { get; init; } + public string? ResourceServerUrl { get; init; } + public bool IsDev { get; init; } + public IReadOnlyDictionary RawData { get; init; } = new Dictionary(); + + public static WelcomeInfo FromDictionary(IDictionary data) + { + return new WelcomeInfo + { + WorldName = data.GetString(k.worldName, "Perpetuum"), + Version = data.GetString("version", string.Empty), + OSTime = data.GetDateTime(k.OSTime), + SteamLoginEnabled = data.GetBool("steamLoginEnabled"), + ResourceServerUrl = data.TryGetValue("resourceServerURL", out var rUrl) && rUrl != null ? rUrl.ToString() : null, + IsDev = data.GetBool("dev"), + RawData = (data as IReadOnlyDictionary) ?? new Dictionary(data) + }; + } + } +} diff --git a/src/Perpetuum.CliClient/Perpetuum.CliClient.csproj b/src/Perpetuum.CliClient/Perpetuum.CliClient.csproj new file mode 100644 index 0000000..e3605ae --- /dev/null +++ b/src/Perpetuum.CliClient/Perpetuum.CliClient.csproj @@ -0,0 +1,20 @@ + + + + Exe + net8.0 + enable + annotations + true + x64 + $(NoWarn);CA1416 + Perpetuum.CliClient + Perpetuum.CliClient + + + + + + + + diff --git a/src/Perpetuum.CliClient/PerpetuumClient.cs b/src/Perpetuum.CliClient/PerpetuumClient.cs new file mode 100644 index 0000000..a0cecd2 --- /dev/null +++ b/src/Perpetuum.CliClient/PerpetuumClient.cs @@ -0,0 +1,484 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Sockets; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Perpetuum.CliClient.Models; +using Perpetuum.Network; + +namespace Perpetuum.CliClient +{ + public class PerpetuumClient : IDisposable + { + private PerpetuumClientConnection? _connection; + private Dictionary? _definitionNamesCache; + private bool _isDisposed; + + public bool IsConnected => _connection != null; + public WelcomeInfo? Welcome { get; private set; } + public AccountInfo? Account { get; private set; } + public CharacterSelectResult? SelectedCharacter { get; private set; } + public IPEndPoint? RemoteEndPoint { get; private set; } + + public event Action? MessageReceived; + public event Action? ChatMessageReceived; + public event Action? RawMessageReceived; + public event Action? Disconnected; + + public static string HashPassword(string password) + { + if (string.IsNullOrEmpty(password)) + { + return string.Empty; + } + + // If already a 40-character hex string, assume it is already SHA-1 hashed + if (password.Length == 40 && password.All(c => Uri.IsHexDigit(c))) + { + return password.ToUpperInvariant(); + } + + byte[] bytes = Encoding.ASCII.GetBytes(password); + byte[] hash = SHA1.HashData(bytes); + return Convert.ToHexString(hash).ToUpperInvariant(); + } + + public async Task ConnectAsync(string host, int port, TimeSpan? timeout = null, CancellationToken cancellationToken = default) + { + var effectiveTimeout = timeout ?? TimeSpan.FromSeconds(15); + var endPoint = await ResolveEndPointAsync(host, port, cancellationToken); + RemoteEndPoint = endPoint; + + _connection = PerpetuumClientConnection.Connect(endPoint); + _connection.MessageReceived += msg => + { + if (msg.Command?.Text == Commands.ChannelNotification.Text && msg.Data != null) + { + var chat = ChatMessage.FromNotification(msg.Data); + if (chat != null) + { + ChatMessageReceived?.Invoke(chat); + } + } + MessageReceived?.Invoke(msg); + }; + _connection.Received += text => RawMessageReceived?.Invoke(text); + _connection.Disconnected += conn => Disconnected?.Invoke(); + + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + cts.CancelAfter(effectiveTimeout); + + try + { + var welcomeMessage = await _connection.SendHandshakeAsync().WaitAsync(cts.Token); + CheckResponseError(welcomeMessage); + Welcome = WelcomeInfo.FromDictionary(welcomeMessage.Data); + return Welcome; + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + throw new TimeoutException($"Connection handshake timed out after {effectiveTimeout.TotalSeconds:F0}s connecting to {host}:{port}"); + } + } + + public async Task SignInAsync(string email, string password, bool alreadyHashed = false, TimeSpan? timeout = null, CancellationToken cancellationToken = default) + { + EnsureConnected(); + var passwordHash = alreadyHashed ? password.ToUpperInvariant() : HashPassword(password); + + var data = new Dictionary + { + { k.email, email }, + { k.password, passwordHash }, + { k.client, 0 }, + { k.hash, "CLI-" + Environment.MachineName }, + { k.language, 0 } + }; + + var request = new Message(Commands.SignIn, data); + var response = await SendAsync(request, timeout, cancellationToken); + CheckResponseError(response); + + Account = AccountInfo.FromDictionary(response.Data); + return Account; + } + + public async Task GetCharacterListAsync(TimeSpan? timeout = null, CancellationToken cancellationToken = default) + { + EnsureConnected(); + var request = new Message(Commands.CharacterList, new Dictionary()); + var response = await SendAsync(request, timeout, cancellationToken); + CheckResponseError(response); + + return CharacterListResult.FromDictionary(response.Data); + } + + public async Task SelectCharacterAsync(int characterId, long baseEid = 0, string baseName = "", TimeSpan? timeout = null, CancellationToken cancellationToken = default) + { + EnsureConnected(); + var data = new Dictionary + { + { k.characterID, characterId }, + { k.language, 0 } + }; + + var request = new Message(Commands.CharacterSelect, data); + var response = await SendAsync(request, timeout, cancellationToken); + CheckResponseError(response); + + SelectedCharacter = CharacterSelectResult.FromDictionary(response.Data); + if (baseEid > 0 && SelectedCharacter.BaseEid == 0) + { + SelectedCharacter.BaseEid = baseEid; + } + if (!string.IsNullOrEmpty(baseName) && string.IsNullOrEmpty(SelectedCharacter.BaseName)) + { + SelectedCharacter.BaseName = baseName; + } + + return SelectedCharacter; + } + + public async Task DeselectCharacterAsync(TimeSpan? timeout = null, CancellationToken cancellationToken = default) + { + if (!IsConnected || SelectedCharacter == null) + { + return; + } + + var request = new Message(Commands.CharacterDeselect, new Dictionary()); + try + { + var response = await SendAsync(request, timeout ?? TimeSpan.FromSeconds(5), cancellationToken); + CheckResponseError(response); + } + finally + { + SelectedCharacter = null; + } + } + + public async Task SignOutAsync(TimeSpan? timeout = null, CancellationToken cancellationToken = default) + { + if (!IsConnected || Account == null) + { + return; + } + + if (SelectedCharacter != null) + { + await DeselectCharacterAsync(timeout, cancellationToken); + } + + var request = new Message(Commands.SignOut, new Dictionary()); + try + { + var response = await SendAsync(request, timeout ?? TimeSpan.FromSeconds(5), cancellationToken); + CheckResponseError(response); + } + finally + { + Account = null; + SelectedCharacter = null; + } + } + + public async Task CreateAccountAsync(string email, string password, TimeSpan? timeout = null, CancellationToken cancellationToken = default) + { + EnsureConnected(); + var passwordHash = HashPassword(password); + var cmd = Commands.GetCommandByText("accountOpenCreate") ?? new Command("accountOpenCreate") { AccessLevel = AccessLevel.notDefined }; + + var data = new Dictionary + { + { k.email, email }, + { k.password, passwordHash } + }; + + var request = new Message(cmd, data); + var response = await SendAsync(request, timeout, cancellationToken); + CheckResponseError(response); + return response; + } + + public async Task CreateCharacterAsync(string nick, int raceId = 1, int schoolId = 1, int majorId = 1, int sparkId = 1, Dictionary? avatar = null, TimeSpan? timeout = null, CancellationToken cancellationToken = default) + { + EnsureConnected(); + var data = new Dictionary + { + { k.nick, nick }, + { k.avatar, avatar ?? new Dictionary() }, + { k.raceID, raceId }, + { k.schoolID, schoolId }, + { k.majorID, majorId }, + { k.sparkID, sparkId } + }; + + var request = new Message(Commands.CharacterCreate, data); + var response = await SendAsync(request, timeout, cancellationToken); + CheckResponseError(response); + + if (response.Data != null && response.Data.TryGetValue(k.characterID, out var cidObj)) + { + return Convert.ToInt32(cidObj); + } + + return 0; + } + + public async Task> GetEntityDefaultsAsync(bool forceRefresh = false, TimeSpan? timeout = null, CancellationToken cancellationToken = default) + { + if (_definitionNamesCache != null && !forceRefresh) + { + return _definitionNamesCache; + } + + EnsureConnected(); + var request = new Message(Commands.GetEntityDefaults, new Dictionary()); + var response = await SendAsync(request, timeout, cancellationToken); + CheckResponseError(response); + + var map = new Dictionary(); + if (response.Data != null) + { + foreach (var kvp in response.Data) + { + if (kvp.Value is IDictionary defDict) + { + var defId = defDict.GetInt(k.definition); + var defName = defDict.GetString(k.definitionName); + if (defId > 0 && !string.IsNullOrEmpty(defName)) + { + map[defId] = defName; + } + } + } + } + + _definitionNamesCache = map; + return _definitionNamesCache; + } + + public async Task GetStorageAsync(long? baseEid = null, TimeSpan? timeout = null, CancellationToken cancellationToken = default) + { + EnsureConnected(); + long targetBaseEid = 0; + + if (baseEid.HasValue && baseEid.Value > 0) + { + targetBaseEid = baseEid.Value; + } + else if (SelectedCharacter != null && SelectedCharacter.BaseEid > 0) + { + targetBaseEid = SelectedCharacter.BaseEid; + } + + if (targetBaseEid == 0) + { + throw new InvalidOperationException("No station base specified and current character is not docked at a known base."); + } + + if (_definitionNamesCache == null) + { + try + { + await GetEntityDefaultsAsync(timeout: TimeSpan.FromSeconds(5), cancellationToken: cancellationToken); + } + catch + { + // Proceed even if entity defaults lookup fails + } + } + + var data = new Dictionary + { + { k.baseEID, targetBaseEid } + }; + + var request = new Message(Commands.BaseGetMyItems, data); + var response = await SendAsync(request, timeout, cancellationToken); + CheckResponseError(response); + + var result = StorageResult.FromDictionary(response.Data, _definitionNamesCache); + if (string.IsNullOrEmpty(result.BaseName) && SelectedCharacter != null && !string.IsNullOrEmpty(SelectedCharacter.BaseName)) + { + result.BaseName = SelectedCharacter.BaseName; + } + + return result; + } + + public async Task> GetChannelsAsync(bool myChannelsOnly = false, TimeSpan? timeout = null, CancellationToken cancellationToken = default) + { + EnsureConnected(); + var cmd = myChannelsOnly ? Commands.ChannelMyList : Commands.ChannelList; + var request = new Message(cmd, new Dictionary()); + var response = await SendAsync(request, timeout, cancellationToken); + CheckResponseError(response); + + var list = new List(); + if (response.Data != null) + { + foreach (var kvp in response.Data) + { + if (kvp.Value is IDictionary channelDict) + { + list.Add(ChannelInfo.FromDictionary(channelDict)); + } + } + } + + return list; + } + + public async Task JoinChannelAsync(string channelName, string password = "", TimeSpan? timeout = null, CancellationToken cancellationToken = default) + { + EnsureConnected(); + var data = new Dictionary + { + { k.channel, channelName }, + { k.password, password } + }; + + var request = new Message(Commands.ChannelJoin, data); + var response = await SendAsync(request, timeout, cancellationToken); + CheckResponseError(response); + } + + public async Task LeaveChannelAsync(string channelName, TimeSpan? timeout = null, CancellationToken cancellationToken = default) + { + EnsureConnected(); + var data = new Dictionary + { + { k.channel, channelName } + }; + + var request = new Message(Commands.ChannelLeave, data); + var response = await SendAsync(request, timeout, cancellationToken); + CheckResponseError(response); + } + + public async Task SendChatMessageAsync(string channelName, string messageText, TimeSpan? timeout = null, CancellationToken cancellationToken = default) + { + EnsureConnected(); + var data = new Dictionary + { + { k.channel, channelName }, + { k.message, messageText } + }; + + var request = new Message(Commands.ChannelTalk, data); + var response = await SendAsync(request, timeout, cancellationToken); + CheckResponseError(response); + } + + public async Task SendCommandAsync(string commandText, Dictionary? parameters = null, string target = "", TimeSpan? timeout = null, CancellationToken cancellationToken = default) + { + EnsureConnected(); + var cmd = Commands.GetCommandByText(commandText) ?? new Command(commandText); + var request = new Message(cmd, parameters ?? new Dictionary()) + { + Sender = target + }; + + var response = await SendAsync(request, timeout, cancellationToken); + CheckResponseError(response); + return response; + } + + public async Task SendAsync(IMessage clientMessage, TimeSpan? timeout = null, CancellationToken cancellationToken = default) + { + EnsureConnected(); + var effectiveTimeout = timeout ?? TimeSpan.FromSeconds(15); + + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + cts.CancelAfter(effectiveTimeout); + + try + { + var sendTask = _connection!.SendAsync(clientMessage); + return await sendTask.WaitAsync(cts.Token); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + throw new TimeoutException($"Command '{clientMessage.Command?.Text}' timed out after {effectiveTimeout.TotalSeconds:F0}s waiting for server reply"); + } + } + + public static void CheckResponseError(IMessage response) + { + if (response.Data == null) + { + return; + } + + if (response.Data.TryGetValue(k.rErr, out var errObj) && errObj != null) + { + var errorCode = (ErrorCodes)Convert.ToInt32(errObj); + if (errorCode != ErrorCodes.NoError) + { + IDictionary? extra = null; + if (response.Data.TryGetValue(k.extra, out var extraObj) && extraObj is IDictionary ed) + { + extra = ed; + } + + throw new PerpetuumClientException(errorCode, extraData: extra != null ? new Dictionary(extra) : null); + } + } + } + + private void EnsureConnected() + { + if (_connection == null) + { + throw new InvalidOperationException("Not connected to a Perpetuum server. Call ConnectAsync first."); + } + } + + private static async Task ResolveEndPointAsync(string host, int port, CancellationToken cancellationToken) + { + if (IPAddress.TryParse(host, out var ip)) + { + return new IPEndPoint(ip, port); + } + + var addresses = await Dns.GetHostAddressesAsync(host, cancellationToken); + var v4 = addresses.FirstOrDefault(a => a.AddressFamily == AddressFamily.InterNetwork) + ?? addresses.FirstOrDefault(); + + if (v4 == null) + { + throw new SocketException((int)SocketError.HostNotFound); + } + + return new IPEndPoint(v4, port); + } + + public void Disconnect() + { + if (_connection != null) + { + _connection.Disconnect(); + _connection.Dispose(); + _connection = null; + } + } + + public void Dispose() + { + if (_isDisposed) + { + return; + } + + _isDisposed = true; + Disconnect(); + GC.SuppressFinalize(this); + } + } +} diff --git a/src/Perpetuum.CliClient/PerpetuumClientConnection.cs b/src/Perpetuum.CliClient/PerpetuumClientConnection.cs new file mode 100644 index 0000000..3433ec9 --- /dev/null +++ b/src/Perpetuum.CliClient/PerpetuumClientConnection.cs @@ -0,0 +1,155 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Threading.Tasks; +using Perpetuum.GenXY; +using Perpetuum.Network; + +namespace Perpetuum.CliClient +{ + public class PerpetuumClientConnection : EncryptedTcpConnection + { + private readonly Rc4 _rc4; + private readonly ConcurrentDictionary>> _commandQueue = + new(StringComparer.OrdinalIgnoreCase); + + public event Action? MessageReceived; + public new event Action? Received; + + public PerpetuumClientConnection(Socket socket) : base(socket) + { + _rc4 = new Rc4(FastRandom.NextBytes(40)); + ObjectHelper.Swap(ref inIncrement, ref outIncrement); + ObjectHelper.Swap(ref inDecodingByte, ref outEncodingByte); + } + + public static PerpetuumClientConnection Connect(IPEndPoint endPoint) + { + var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp) + { + NoDelay = true + }; + socket.Connect(endPoint); + var connection = new PerpetuumClientConnection(socket); + connection.Receive(); + return connection; + } + + public Task SendHandshakeAsync() + { + byte[]? encryptedKey = Rsa.Encrypt(_rc4.streamKey); + if (encryptedKey == null) + { + throw new InvalidOperationException("Failed to encrypt RSA handshake stream key."); + } + + byte[] data = new byte[encryptedKey.Length + 4]; + Array.Copy(encryptedKey, 0, data, 4, encryptedKey.Length); + + var source = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + EnqueueCompletionSource(Commands.Welcome.Text, source); + + // Bypass RC4 for RSA handshake, rolling XOR cipher is still applied by base.Send + base.Send(data); + return source.Task; + } + + public Task SendAsync(IMessage clientMessage) + { + var commandText = clientMessage.Command?.Text ?? string.Empty; + var source = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + EnqueueCompletionSource(commandText, source); + + Send(clientMessage.ToBytes()); + return source.Task; + } + + public override void Send(byte[] data) + { + byte[] t = new byte[data.Length + 4]; + Array.Copy(data, 0, t, 4, data.Length); + _rc4.Encrypt(t, 1, t.Length - 1); + base.Send(t); + } + + private void EnqueueCompletionSource(string commandText, TaskCompletionSource source) + { + var q = _commandQueue.GetOrAdd(commandText, _ => new ConcurrentQueue>()); + q.Enqueue(source); + } + + protected override void OnReceived(byte[] data) + { + if (data == null || data.Length == 0) + { + return; + } + + _rc4.Decrypt(data, 1, data.Length - 1); + + byte compressionLevel = data[0]; + string messageText; + + if (compressionLevel == 2) + { + byte[] decompressed = GZip.Decompress(data, 5); + int offset = 3; // Decompressed stream includes 3 leading zero bytes + int length = Math.Max(0, decompressed.Length - offset); + messageText = Encoding.UTF8.GetString(decompressed, offset, length); + } + else + { + int offset = 4; // Uncompressed includes 1 byte compression level + 3 zero bytes + int length = Math.Max(0, data.Length - offset); + messageText = Encoding.UTF8.GetString(data, offset, length); + } + + OnReceivedMessageText(messageText); + base.OnReceived(data); + } + + private void OnReceivedMessageText(string messageText) + { + if (string.IsNullOrEmpty(messageText)) + { + return; + } + + var parts = messageText.Split(new[] { ':' }, 3); + var commandText = parts[0]; + var command = Commands.GetCommandByText(commandText) ?? new Command(commandText); + var sender = parts.Length > 1 ? parts[1] : string.Empty; + var genxyData = parts.Length > 2 ? parts[2] : string.Empty; + + Dictionary deserializedData; + try + { + deserializedData = GenxyConverter.Deserialize(genxyData); + } + catch + { + deserializedData = new Dictionary(); + } + + var message = new Message(command, deserializedData) + { + Sender = sender + }; + + MessageReceived?.Invoke(message); + + if (_commandQueue.TryGetValue(commandText, out var q)) + { + if (q.TryDequeue(out var source)) + { + source.TrySetResult(message); + } + } + + Received?.Invoke(messageText); + } + } +} diff --git a/src/Perpetuum.CliClient/PerpetuumClientException.cs b/src/Perpetuum.CliClient/PerpetuumClientException.cs new file mode 100644 index 0000000..ea6ba96 --- /dev/null +++ b/src/Perpetuum.CliClient/PerpetuumClientException.cs @@ -0,0 +1,42 @@ +using System; +using System.Collections.Generic; + +namespace Perpetuum.CliClient +{ + public class PerpetuumClientException : Exception + { + public ErrorCodes ErrorCode { get; } + public IReadOnlyDictionary? ExtraData { get; } + + public PerpetuumClientException(ErrorCodes errorCode, string? message = null, IReadOnlyDictionary? extraData = null) + : base(message ?? FormatErrorMessage(errorCode, extraData)) + { + ErrorCode = errorCode; + ExtraData = extraData; + } + + public static string FormatErrorMessage(ErrorCodes errorCode, IReadOnlyDictionary? extra = null) + { + return errorCode switch + { + ErrorCodes.NoSuchUser => "Invalid username/email or password.", + ErrorCodes.AccountNotFound => "Account not found.", + ErrorCodes.AccountBanned => "Account is currently banned.", + ErrorCodes.AccountHasBeenDisconnected => "Account was already logged in on another session and has been disconnected. Please try again.", + ErrorCodes.NoSimultaneousLoginsAllowed => "Simultaneous logins are not allowed.", + ErrorCodes.RelayIsClosedForPublic => "Server is currently in maintenance or restricted to administrators.", + ErrorCodes.NotSignedIn => "You are not signed in.", + ErrorCodes.AccessDenied => "Access denied for this resource or character.", + ErrorCodes.OffensiveNick => "This character's nickname was flagged as offensive and must be renamed.", + ErrorCodes.CharacterNotFound => "Character not found.", + ErrorCodes.CharacterAlreadySelected => "A character is already selected in this session.", + ErrorCodes.AccountAlreadyExists => "An account with this email already exists.", + ErrorCodes.NoSuchCommand => "Unknown command.", + ErrorCodes.InsufficientPrivileges => "Insufficient privileges to execute this command.", + ErrorCodes.RequiredArgumentIsNotSpecified => "Required arguments were missing.", + ErrorCodes.InviteOnlyServer => "This server is invite-only.", + _ => $"Server error: {errorCode} (Code {(int)errorCode})" + }; + } + } +} diff --git a/src/Perpetuum.CliClient/Program.cs b/src/Perpetuum.CliClient/Program.cs new file mode 100644 index 0000000..fedbfec --- /dev/null +++ b/src/Perpetuum.CliClient/Program.cs @@ -0,0 +1,878 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Perpetuum.CliClient.Models; +using Perpetuum.GenXY; + +namespace Perpetuum.CliClient +{ + internal class Program + { + private static PerpetuumClient? _client; + private static bool _exitRequested; + private static string _selectedCharacterNick = string.Empty; + private static long _selectedCharacterBaseEid; + private static string _selectedCharacterBaseName = string.Empty; + private static bool _selectedCharacterIsDocked; + private static string _activeChannelName = string.Empty; + private static List _lastChannelsList = new(); + + private static async Task Main(string[] args) + { + var options = CommandLineOptions.Parse(args); + if (options.ShowHelp) + { + CommandLineOptions.PrintHelp(); + return 0; + } + + ConsoleHelper.WriteBanner(); + + using var client = new PerpetuumClient(); + _client = client; + + // Setup graceful shutdown on Ctrl+C + Console.CancelKeyPress += (sender, e) => + { + e.Cancel = true; + _exitRequested = true; + ConsoleHelper.Warn("Shutdown requested, closing session..."); + Task.Run(async () => + { + try + { + if (_client?.SelectedCharacter != null) + { + await _client.DeselectCharacterAsync(); + } + if (_client?.Account != null) + { + await _client.SignOutAsync(); + } + } + catch { } + finally + { + _client?.Disconnect(); + Environment.Exit(0); + } + }); + }; + + // Register background message logging + client.MessageReceived += msg => + { + if (msg.Command == Commands.SignIn || msg.Command == Commands.CharacterList || msg.Command == Commands.CharacterSelect || msg.Command == Commands.Welcome || msg.Command == Commands.ChannelNotification) + { + return; // already handled by command response tasks or chat event + } + + var sender = (msg as Message)?.Sender ?? string.Empty; + Console.ForegroundColor = ConsoleColor.DarkMagenta; + Console.WriteLine($"\n[SERVER MSG] Command: {msg.Command?.Text} Sender: {sender}"); + if (msg.Data != null && msg.Data.Count > 0) + { + foreach (var kvp in msg.Data) + { + Console.WriteLine($" {kvp.Key}: {kvp.Value}"); + } + } + Console.ResetColor(); + }; + + // Register chat message notifications + client.ChatMessageReceived += chat => + { + ConsoleHelper.PrintChatMessage(chat); + }; + + client.Disconnected += () => + { + if (!_exitRequested) + { + ConsoleHelper.Warn("Disconnected from server."); + } + }; + + try + { + // Step 1: Connect & Handshake + ConsoleHelper.Info($"Connecting to Perpetuum Server at {options.Host}:{options.Port}..."); + var welcome = await client.ConnectAsync(options.Host, options.Port); + ConsoleHelper.Success("Connected and RSA/RC4 encryption handshake established."); + ConsoleHelper.PrintWelcome(welcome, $"{options.Host}:{options.Port}"); + + // Step 2 (Optional): Create Account + if (options.CreateAccount) + { + var email = options.Email; + if (string.IsNullOrWhiteSpace(email)) + { + email = ConsoleHelper.ReadLine("Enter new account email"); + } + var password = options.Password; + if (string.IsNullOrWhiteSpace(password)) + { + password = ConsoleHelper.ReadPassword("Enter new account password"); + } + + ConsoleHelper.Info($"Creating account for {email}..."); + await client.CreateAccountAsync(email, password); + ConsoleHelper.Success("Account created successfully!"); + options.Email = email; + options.Password = password; + } + + // Step 3: Login + AccountInfo account; + while (true) + { + var email = options.Email; + if (string.IsNullOrWhiteSpace(email)) + { + email = ConsoleHelper.ReadLine("Email / Username"); + } + + var password = options.Password; + var passwordHash = options.PasswordHash; + + if (string.IsNullOrWhiteSpace(password) && string.IsNullOrWhiteSpace(passwordHash)) + { + password = ConsoleHelper.ReadPassword("Password"); + } + + ConsoleHelper.Info($"Signing in as '{email}'..."); + try + { + account = await client.SignInAsync(email, password ?? passwordHash!, alreadyHashed: !string.IsNullOrEmpty(passwordHash)); + ConsoleHelper.Success($"Signed in successfully as {account.Email} (Account ID: {account.AccountId})"); + ConsoleHelper.PrintAccount(account); + break; + } + catch (PerpetuumClientException pex) + { + ConsoleHelper.Error(pex.Message); + if (options.NonInteractive) + { + return 1; + } + + // Reset credentials so user can re-enter + options.Email = null; + options.Password = null; + options.PasswordHash = null; + } + } + + // Step 3.5 (Optional): Create Character via CLI option + if (!string.IsNullOrWhiteSpace(options.CreateCharacterNick)) + { + ConsoleHelper.Info($"Creating character '{options.CreateCharacterNick}'..."); + var newCharId = await client.CreateCharacterAsync(options.CreateCharacterNick, options.RaceId, options.SchoolId, options.MajorId, options.SparkId); + ConsoleHelper.Success($"Character '{options.CreateCharacterNick}' created successfully! (Character ID: {newCharId})"); + options.Character = options.CreateCharacterNick; + } + + // Step 4: Get Character List + ConsoleHelper.Info("Retrieving characters..."); + var charListResult = await client.GetCharacterListAsync(); + ConsoleHelper.PrintCharactersTable(charListResult.Characters, charListResult.ExtensionPoints); + + // Step 5: Select Character + CharacterSummary? targetChar = null; + if (!string.IsNullOrWhiteSpace(options.Character)) + { + if (int.TryParse(options.Character, out var cid)) + { + targetChar = charListResult.Characters.FirstOrDefault(c => c.CharacterId == cid); + } + if (targetChar == null) + { + targetChar = charListResult.Characters.FirstOrDefault(c => string.Equals(c.Nick, options.Character, StringComparison.OrdinalIgnoreCase)); + } + + if (targetChar == null) + { + ConsoleHelper.Warn($"Character '{options.Character}' not found on account."); + } + } + + if (targetChar == null && charListResult.Characters.Count == 0 && !options.NonInteractive) + { + var createChoice = ConsoleHelper.ReadLine("No characters found on this account. Create a new character now? (Y/n)", "Y"); + if (createChoice.Equals("y", StringComparison.OrdinalIgnoreCase) || createChoice.Equals("yes", StringComparison.OrdinalIgnoreCase)) + { + var newNick = ConsoleHelper.ReadLine("Enter character nickname"); + if (!string.IsNullOrWhiteSpace(newNick)) + { + ConsoleHelper.Info($"Creating character '{newNick}'..."); + var newId = await client.CreateCharacterAsync(newNick); + ConsoleHelper.Success($"Character '{newNick}' created! (ID: {newId})"); + charListResult = await client.GetCharacterListAsync(); + ConsoleHelper.PrintCharactersTable(charListResult.Characters, charListResult.ExtensionPoints); + targetChar = charListResult.Characters.FirstOrDefault(c => c.CharacterId == newId); + } + } + } + + if (targetChar == null && charListResult.Characters.Count > 0) + { + if (options.NonInteractive) + { + // In non-interactive mode with no character specified, pick the first character + targetChar = charListResult.Characters[0]; + } + else + { + while (targetChar == null) + { + var input = ConsoleHelper.ReadLine($"Select character (1-{charListResult.Characters.Count}, ID, Nick, 'new' to create, or 'q' to skip)", "1"); + if (string.Equals(input, "q", StringComparison.OrdinalIgnoreCase) || string.Equals(input, "quit", StringComparison.OrdinalIgnoreCase)) + { + break; + } + + if (string.Equals(input, "new", StringComparison.OrdinalIgnoreCase) || string.Equals(input, "create", StringComparison.OrdinalIgnoreCase)) + { + var newNick = ConsoleHelper.ReadLine("Enter character nickname"); + if (!string.IsNullOrWhiteSpace(newNick)) + { + ConsoleHelper.Info($"Creating character '{newNick}'..."); + var newId = await client.CreateCharacterAsync(newNick); + ConsoleHelper.Success($"Character '{newNick}' created! (ID: {newId})"); + charListResult = await client.GetCharacterListAsync(); + ConsoleHelper.PrintCharactersTable(charListResult.Characters, charListResult.ExtensionPoints); + targetChar = charListResult.Characters.FirstOrDefault(c => c.CharacterId == newId); + if (targetChar != null) break; + } + continue; + } + + if (int.TryParse(input, out var idx) && idx >= 1 && idx <= charListResult.Characters.Count) + { + targetChar = charListResult.Characters[idx - 1]; + } + else if (int.TryParse(input, out var charId)) + { + targetChar = charListResult.Characters.FirstOrDefault(c => c.CharacterId == charId); + } + else + { + targetChar = charListResult.Characters.FirstOrDefault(c => string.Equals(c.Nick, input, StringComparison.OrdinalIgnoreCase)); + } + + if (targetChar == null) + { + ConsoleHelper.Warn("Invalid selection, please try again."); + } + } + } + } + + if (targetChar != null) + { + ConsoleHelper.Info($"Selecting character '{targetChar.Nick}' (ID: {targetChar.CharacterId})..."); + var selectResult = await client.SelectCharacterAsync(targetChar.CharacterId, targetChar.BaseEid, targetChar.BaseName); + _selectedCharacterNick = targetChar.Nick; + _selectedCharacterBaseEid = targetChar.BaseEid; + _selectedCharacterBaseName = targetChar.BaseName; + _selectedCharacterIsDocked = targetChar.IsDocked; + ConsoleHelper.Success($"Character '{targetChar.Nick}' selected successfully!"); + ConsoleHelper.PrintSelectedCharacter(selectResult, targetChar.Nick); + + if (targetChar.IsDocked) + { + if (options.ListStorage) + { + ConsoleHelper.Info("Retrieving private station storage..."); + try + { + var storage = await client.GetStorageAsync(targetChar.BaseEid); + ConsoleHelper.PrintStorageTable(storage, targetChar.Nick); + } + catch (Exception ex) + { + ConsoleHelper.Warn($"Could not retrieve station storage: {ex.Message}"); + } + } + else + { + var baseLabel = !string.IsNullOrEmpty(targetChar.BaseName) ? targetChar.BaseName : $"Base #{targetChar.BaseEid}"; + ConsoleHelper.Info($"Docked at: {baseLabel}. Type 'storage' to view private storage items."); + } + } + } + else + { + ConsoleHelper.Info("No character selected."); + } + + // Handle CLI channel / chat operations + if (options.ListChannels) + { + ConsoleHelper.Info("Retrieving chat channels..."); + var channels = await client.GetChannelsAsync(); + _lastChannelsList = channels; + ConsoleHelper.PrintChannelsTable(channels); + } + + if (!string.IsNullOrWhiteSpace(options.Channel)) + { + _activeChannelName = options.Channel; + ConsoleHelper.Info($"Joining channel '{_activeChannelName}'..."); + try + { + await client.JoinChannelAsync(_activeChannelName); + ConsoleHelper.Success($"Joined channel '{_activeChannelName}'."); + } + catch (PerpetuumClientException pex) + { + ConsoleHelper.Warn($"Join channel notice: {pex.Message}"); + } + } + + if (!string.IsNullOrWhiteSpace(options.ChatMessage)) + { + var targetChan = !string.IsNullOrWhiteSpace(options.Channel) ? options.Channel : "General"; + ConsoleHelper.Info($"Sending chat message to channel '{targetChan}': {options.ChatMessage}"); + await client.SendChatMessageAsync(targetChan, options.ChatMessage); + ConsoleHelper.Success("Chat message sent successfully."); + } + + if (options.NonInteractive) + { + ConsoleHelper.Success("Operation completed in non-interactive mode."); + return 0; + } + + // Step 6: Interactive REPL loop + await RunInteractiveLoopAsync(client); + return 0; + } + catch (Exception ex) + { + ConsoleHelper.Error($"Fatal error: {ex.Message}"); + return 1; + } + finally + { + try + { + if (client.SelectedCharacter != null) + { + await client.DeselectCharacterAsync(); + } + if (client.Account != null) + { + await client.SignOutAsync(); + } + } + catch { } + client.Disconnect(); + } + } + + private static async Task RunInteractiveLoopAsync(PerpetuumClient client) + { + Console.WriteLine(); + ConsoleHelper.Info("Entered interactive shell. Type 'help' for commands, 'exit' to quit."); + Console.WriteLine(); + + while (!_exitRequested && client.IsConnected) + { + var promptLabel = BuildPromptLabel(client); + + Console.ForegroundColor = ConsoleColor.Green; + Console.Write(promptLabel); + Console.ResetColor(); + + var line = Console.ReadLine(); + if (line == null) + { + break; + } + + var trimmed = line.Trim(); + if (string.IsNullOrEmpty(trimmed)) + { + continue; + } + + var parts = trimmed.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries); + var cmd = parts[0].ToLowerInvariant(); + + try + { + switch (cmd) + { + case "help": + case "?": + PrintShellHelp(); + break; + + case "whoami": + case "me": + case "info": + if (client.Account != null) + { + ConsoleHelper.PrintAccount(client.Account); + } + if (client.SelectedCharacter != null) + { + ConsoleHelper.PrintSelectedCharacter(client.SelectedCharacter, _selectedCharacterNick); + } + if (!string.IsNullOrEmpty(_activeChannelName)) + { + ConsoleHelper.Info($"Active chat channel: #{_activeChannelName}"); + } + break; + + case "chars": + case "list": + ConsoleHelper.Info("Refreshing character list..."); + var charList = await client.GetCharacterListAsync(); + ConsoleHelper.PrintCharactersTable(charList.Characters, charList.ExtensionPoints); + break; + + case "create-char": + case "createchar": + case "newchar": + case "char-create": + string charNick = parts.Length > 1 ? parts[1] : string.Empty; + if (string.IsNullOrWhiteSpace(charNick)) + { + charNick = ConsoleHelper.ReadLine("Enter character nickname"); + } + if (string.IsNullOrWhiteSpace(charNick)) + { + ConsoleHelper.Warn("Character nickname cannot be empty."); + break; + } + + int race = 1; + int school = 1; + int major = 1; + int spark = 1; + + for (int i = 2; i < parts.Length; i++) + { + var eqIdx = parts[i].IndexOf('='); + if (eqIdx > 0) + { + var key = parts[i].Substring(0, eqIdx).ToLowerInvariant(); + var val = parts[i].Substring(eqIdx + 1); + if (key == "race" && int.TryParse(val, out var rv)) race = rv; + else if (key == "school" && int.TryParse(val, out var sv)) school = sv; + else if (key == "major" && int.TryParse(val, out var mv)) major = mv; + else if (key == "spark" && int.TryParse(val, out var spv)) spark = spv; + } + else if (int.TryParse(parts[i], out var numVal)) + { + if (i == 2) race = numVal; + else if (i == 3) school = numVal; + else if (i == 4) major = numVal; + else if (i == 5) spark = numVal; + } + } + + ConsoleHelper.Info($"Creating character '{charNick}' (Race: {race}, School: {school})..."); + var createdCharId = await client.CreateCharacterAsync(charNick, race, school, major, spark); + ConsoleHelper.Success($"Character '{charNick}' created successfully! (Character ID: {createdCharId})"); + + var refreshedChars = await client.GetCharacterListAsync(); + ConsoleHelper.PrintCharactersTable(refreshedChars.Characters, refreshedChars.ExtensionPoints); + + var selectNow = ConsoleHelper.ReadLine($"Select character '{charNick}' now? (Y/n)", "Y"); + if (selectNow.Equals("y", StringComparison.OrdinalIgnoreCase) || selectNow.Equals("yes", StringComparison.OrdinalIgnoreCase)) + { + if (client.SelectedCharacter != null) + { + await client.DeselectCharacterAsync(); + } + var createdChar = refreshedChars.Characters.FirstOrDefault(c => c.CharacterId == createdCharId); + var baseEid = createdChar?.BaseEid ?? 0; + var baseName = createdChar?.BaseName ?? string.Empty; + var isDocked = createdChar?.IsDocked ?? true; + + var selectRes = await client.SelectCharacterAsync(createdCharId, baseEid, baseName); + _selectedCharacterNick = charNick; + _selectedCharacterBaseEid = baseEid; + _selectedCharacterBaseName = baseName; + _selectedCharacterIsDocked = isDocked; + ConsoleHelper.Success($"Character '{charNick}' selected."); + ConsoleHelper.PrintSelectedCharacter(selectRes, charNick); + } + break; + + case "select": + if (parts.Length < 2) + { + ConsoleHelper.Warn("Usage: select "); + break; + } + var listForSelect = await client.GetCharacterListAsync(); + var targetArg = parts[1]; + CharacterSummary? target = null; + if (int.TryParse(targetArg, out var num) && num >= 1 && num <= listForSelect.Characters.Count) + { + target = listForSelect.Characters[num - 1]; + } + else if (int.TryParse(targetArg, out var cid)) + { + target = listForSelect.Characters.FirstOrDefault(c => c.CharacterId == cid); + } + else + { + target = listForSelect.Characters.FirstOrDefault(c => string.Equals(c.Nick, targetArg, StringComparison.OrdinalIgnoreCase)); + } + + if (target == null) + { + ConsoleHelper.Error($"Character '{targetArg}' not found."); + break; + } + + ConsoleHelper.Info($"Selecting character '{target.Nick}' (ID: {target.CharacterId})..."); + var selRes = await client.SelectCharacterAsync(target.CharacterId, target.BaseEid, target.BaseName); + _selectedCharacterNick = target.Nick; + _selectedCharacterBaseEid = target.BaseEid; + _selectedCharacterBaseName = target.BaseName; + _selectedCharacterIsDocked = target.IsDocked; + ConsoleHelper.Success($"Character '{target.Nick}' selected."); + ConsoleHelper.PrintSelectedCharacter(selRes, target.Nick); + if (target.IsDocked) + { + var baseLabel = !string.IsNullOrEmpty(target.BaseName) ? target.BaseName : $"Base #{target.BaseEid}"; + ConsoleHelper.Info($"Docked at: {baseLabel}. Type 'storage' to view private storage items."); + } + break; + + case "deselect": + if (client.SelectedCharacter == null) + { + ConsoleHelper.Warn("No character currently selected."); + break; + } + ConsoleHelper.Info("Deselecting character..."); + await client.DeselectCharacterAsync(); + _selectedCharacterNick = string.Empty; + _selectedCharacterBaseEid = 0; + _selectedCharacterBaseName = string.Empty; + _selectedCharacterIsDocked = false; + _activeChannelName = string.Empty; + ConsoleHelper.Success("Character deselected."); + break; + + case "channels": + case "chans": + bool myOnly = parts.Length > 1 && string.Equals(parts[1], "my", StringComparison.OrdinalIgnoreCase); + ConsoleHelper.Info(myOnly ? "Retrieving joined channels..." : "Retrieving public channels..."); + var channels = await client.GetChannelsAsync(myChannelsOnly: myOnly); + _lastChannelsList = channels; + ConsoleHelper.PrintChannelsTable(channels, myOnly ? "MY CHANNELS" : "AVAILABLE CHANNELS"); + break; + + case "channel": + case "chan": + case "select-channel": + if (parts.Length < 2) + { + if (!string.IsNullOrEmpty(_activeChannelName)) + { + ConsoleHelper.Info($"Active channel: #{_activeChannelName}"); + } + else + { + ConsoleHelper.Warn("Usage: channel "); + } + break; + } + + var chanArg = parts[1]; + string targetChanName = chanArg; + + if (int.TryParse(chanArg, out var chanIdx) && chanIdx >= 1 && chanIdx <= _lastChannelsList.Count) + { + targetChanName = _lastChannelsList[chanIdx - 1].Name; + } + + if (targetChanName.StartsWith("#")) + { + targetChanName = targetChanName.Substring(1); + } + + ConsoleHelper.Info($"Selecting channel '{targetChanName}'..."); + try + { + await client.JoinChannelAsync(targetChanName); + } + catch (PerpetuumClientException pex) when (pex.ErrorCode == ErrorCodes.CharacterAlreadyOnChannel) + { + // Already in channel, proceed + } + + _activeChannelName = targetChanName; + ConsoleHelper.Success($"Active channel set to #{_activeChannelName}."); + break; + + case "join": + if (parts.Length < 2) + { + ConsoleHelper.Warn("Usage: join [password]"); + break; + } + var joinName = parts[1].TrimStart('#'); + var password = parts.Length > 2 ? parts[2] : string.Empty; + + ConsoleHelper.Info($"Joining channel '{joinName}'..."); + await client.JoinChannelAsync(joinName, password); + _activeChannelName = joinName; + ConsoleHelper.Success($"Joined and selected channel #{_activeChannelName}."); + break; + + case "leave": + var leaveName = parts.Length > 1 ? parts[1].TrimStart('#') : _activeChannelName; + if (string.IsNullOrEmpty(leaveName)) + { + ConsoleHelper.Warn("Usage: leave "); + break; + } + ConsoleHelper.Info($"Leaving channel '{leaveName}'..."); + await client.LeaveChannelAsync(leaveName); + if (string.Equals(_activeChannelName, leaveName, StringComparison.OrdinalIgnoreCase)) + { + _activeChannelName = string.Empty; + } + ConsoleHelper.Success($"Left channel '{leaveName}'."); + break; + + case "msg": + case "chat": + case "say": + if (parts.Length < 2) + { + ConsoleHelper.Warn("Usage: msg OR msg # "); + break; + } + + string msgChannel = _activeChannelName; + string messageText; + + if (parts[1].StartsWith("#") && parts.Length > 2) + { + msgChannel = parts[1].Substring(1); + messageText = trimmed.Substring(parts[0].Length + parts[1].Length + 2).Trim(); + } + else + { + messageText = trimmed.Substring(parts[0].Length + 1).Trim(); + } + + if (string.IsNullOrEmpty(msgChannel)) + { + ConsoleHelper.Warn("No active channel selected. Use 'channel ' or 'msg # '."); + break; + } + + if (client.SelectedCharacter == null) + { + ConsoleHelper.Warn("Select a character first before chatting."); + break; + } + + await client.SendChatMessageAsync(msgChannel, messageText); + break; + + case "profile": + if (client.SelectedCharacter == null) + { + ConsoleHelper.Warn("Select a character first to view profile."); + break; + } + ConsoleHelper.Info("Requesting character profile..."); + var profileCmd = Commands.GetCommandByText("characterGetMyProfile") ?? new Command("characterGetMyProfile"); + var profRes = await client.SendAsync(new Message(profileCmd, new Dictionary())); + PerpetuumClient.CheckResponseError(profRes); + ConsoleHelper.Success("Profile data received:"); + PrintDictionary(profRes.Data, 1); + break; + + case "storage": + case "hangar": + case "inventory": + case "items": + case "list-storage": + if (client.SelectedCharacter == null) + { + ConsoleHelper.Warn("Select a character first to view station storage."); + break; + } + + long targetBaseEid = _selectedCharacterBaseEid; + if (parts.Length > 1 && long.TryParse(parts[1], out var customBaseEid)) + { + targetBaseEid = customBaseEid; + } + + if (targetBaseEid == 0 && !_selectedCharacterIsDocked) + { + ConsoleHelper.Warn("Character is not currently docked at a station. Usage: storage [baseEid]"); + break; + } + + var baseNameStr = !string.IsNullOrEmpty(_selectedCharacterBaseName) && targetBaseEid == _selectedCharacterBaseEid + ? $"{_selectedCharacterBaseName} (#{targetBaseEid})" + : $"Base #{targetBaseEid}"; + ConsoleHelper.Info($"Retrieving station storage ({baseNameStr})..."); + var storageRes = await client.GetStorageAsync(targetBaseEid > 0 ? targetBaseEid : (long?)null); + ConsoleHelper.PrintStorageTable(storageRes, _selectedCharacterNick); + break; + + case "send": + case "cmd": + if (parts.Length < 2) + { + ConsoleHelper.Warn("Usage: send [key1=val1 key2=val2 ...]"); + break; + } + var commandName = parts[1]; + var paramDict = new Dictionary(); + for (int i = 2; i < parts.Length; i++) + { + var eqIdx = parts[i].IndexOf('='); + if (eqIdx > 0) + { + var k = parts[i].Substring(0, eqIdx); + var v = parts[i].Substring(eqIdx + 1); + if (int.TryParse(v, out var iv)) + paramDict[k] = iv; + else if (long.TryParse(v, out var lv)) + paramDict[k] = lv; + else if (bool.TryParse(v, out var bv)) + paramDict[k] = bv; + else + paramDict[k] = v; + } + } + + ConsoleHelper.Info($"Sending command '{commandName}'..."); + var customRes = await client.SendCommandAsync(commandName, paramDict); + ConsoleHelper.Success($"Received response for '{customRes.Command?.Text}':"); + PrintDictionary(customRes.Data, 1); + break; + + case "status": + case "ping": + ConsoleHelper.Info($"Connected to: {client.RemoteEndPoint}"); + ConsoleHelper.Info($"Session authenticated: {client.Account != null} (Account ID: {client.Account?.AccountId})"); + ConsoleHelper.Info($"Selected character: {(!string.IsNullOrEmpty(_selectedCharacterNick) ? _selectedCharacterNick : "None")}"); + ConsoleHelper.Info($"Active channel: {(!string.IsNullOrEmpty(_activeChannelName) ? $"#{_activeChannelName}" : "None")}"); + break; + + case "cls": + case "clear": + Console.Clear(); + ConsoleHelper.WriteBanner(); + break; + + case "logout": + case "signout": + ConsoleHelper.Info("Signing out..."); + await client.SignOutAsync(); + _selectedCharacterNick = string.Empty; + _activeChannelName = string.Empty; + ConsoleHelper.Success("Signed out."); + return; + + case "exit": + case "quit": + case "q": + ConsoleHelper.Info("Exiting..."); + return; + + default: + ConsoleHelper.Warn($"Unknown command '{cmd}'. Type 'help' for available commands."); + break; + } + } + catch (PerpetuumClientException pex) + { + ConsoleHelper.Error(pex.Message); + } + catch (Exception ex) + { + ConsoleHelper.Error($"Command error: {ex.Message}"); + } + } + } + + private static string BuildPromptLabel(PerpetuumClient client) + { + if (!string.IsNullOrEmpty(_selectedCharacterNick)) + { + if (!string.IsNullOrEmpty(_activeChannelName)) + { + return $"[Perpetuum:{_selectedCharacterNick} (#{_activeChannelName})]> "; + } + return $"[Perpetuum:{_selectedCharacterNick}]> "; + } + + if (client.Account != null) + { + return $"[Perpetuum:{client.Account.Email}]> "; + } + + return "[Perpetuum]> "; + } + + private static void PrintShellHelp() + { + Console.WriteLine(@" +Interactive Commands: + help, ? Show this help message + whoami, me, info Show account, character, and channel details + chars, list List all characters on the account + create-char [race=1] Create a new character (aliases: newchar, createchar) + select <#|id|name> Select a character by table number, ID, or nickname + deselect Deselect current character + storage [baseEid] List private station storage (aliases: hangar, inventory, items) + +Chat & Channels: + channels [public|my] List available or joined chat channels + channel Select/set the active chat channel + join [password] Join a channel and set as active + leave [name] Leave a channel + msg Send a message to the active channel + msg # Send a message to a specific channel + +General & Raw Commands: + profile Request and display character profile + send [k=v ...] Send a raw command to the server with optional key=value arguments + status, ping Show connection and session status + cls, clear Clear console screen + logout, signout Sign out of account + exit, quit, q Disconnect and exit +"); + } + + private static void PrintDictionary(IDictionary? dict, int indent) + { + if (dict == null) return; + var prefix = new string(' ', indent * 2); + foreach (var kvp in dict) + { + if (kvp.Value is IDictionary nested) + { + Console.WriteLine($"{prefix}{kvp.Key}:"); + PrintDictionary(nested, indent + 1); + } + else + { + Console.WriteLine($"{prefix}{kvp.Key}: {kvp.Value}"); + } + } + } + } +} diff --git a/src/Perpetuum.Tests/Perpetuum.Tests.csproj b/src/Perpetuum.Tests/Perpetuum.Tests.csproj index 5b70438..dfa5e55 100644 --- a/src/Perpetuum.Tests/Perpetuum.Tests.csproj +++ b/src/Perpetuum.Tests/Perpetuum.Tests.csproj @@ -19,6 +19,7 @@ + diff --git a/src/Perpetuum.Tests/Unit/CliClientTests.cs b/src/Perpetuum.Tests/Unit/CliClientTests.cs new file mode 100644 index 0000000..f6910ce --- /dev/null +++ b/src/Perpetuum.Tests/Unit/CliClientTests.cs @@ -0,0 +1,625 @@ +using System; +using System.Collections.Generic; +using System.Text; +using Perpetuum.CliClient; +using Perpetuum.CliClient.Models; +using Perpetuum.GenXY; +using Perpetuum.Network; +using Xunit; + +namespace Perpetuum.Tests.Unit +{ + public class CliClientTests + { + [Fact] + public void Password_hashing_produces_uppercase_sha1() + { + var plain = "secretPassword123"; + var hash = PerpetuumClient.HashPassword(plain); + + Assert.Equal(40, hash.Length); + Assert.Equal(hash.ToUpperInvariant(), hash); + Assert.Equal(hash, PerpetuumClient.HashPassword(plain)); + } + + [Fact] + public void Password_hashing_preserves_existing_40_hex_hash() + { + var existingHash = "5BAA61E4C9B93F3F0682250B6CF8331B7EE68FD8"; + var result = PerpetuumClient.HashPassword(existingHash); + + Assert.Equal(existingHash, result); + } + + [Fact] + public void CommandLineOptions_parses_arguments_correctly() + { + var args = new[] + { + "--host", "192.168.1.50", + "--port", "17800", + "--email", "pilot@example.com", + "--password", "myPass", + "--character", "ThunderPilot", + "--channel", "General", + "--chat-msg", "Hello pilots!", + "--list-channels", + "--non-interactive" + }; + + var options = CommandLineOptions.Parse(args); + + Assert.Equal("192.168.1.50", options.Host); + Assert.Equal(17800, options.Port); + Assert.Equal("pilot@example.com", options.Email); + Assert.Equal("myPass", options.Password); + Assert.Equal("ThunderPilot", options.Character); + Assert.Equal("General", options.Channel); + Assert.Equal("Hello pilots!", options.ChatMessage); + Assert.True(options.ListChannels); + Assert.True(options.NonInteractive); + Assert.False(options.ShowHelp); + } + + [Fact] + public void CommandLineOptions_parses_create_character_arguments_correctly() + { + var args = new[] + { + "--email", "pilot@example.com", + "--password", "myPass", + "--create-char", "NewPilotName", + "--race", "2", + "--school", "3", + "--major", "4", + "--spark", "5" + }; + + var options = CommandLineOptions.Parse(args); + + Assert.Equal("pilot@example.com", options.Email); + Assert.Equal("myPass", options.Password); + Assert.Equal("NewPilotName", options.CreateCharacterNick); + Assert.Equal(2, options.RaceId); + Assert.Equal(3, options.SchoolId); + Assert.Equal(4, options.MajorId); + Assert.Equal(5, options.SparkId); + } + + [Fact] + public void ClientDictionaryExtensions_GetBool_handles_types_and_defaults() + { + var dict = new Dictionary + { + { "boolTrue", true }, + { "boolFalse", false }, + { "intOne", 1 }, + { "intZero", 0 }, + { "longOne", 1L }, + { "longZero", 0L }, + { "byteOne", (byte)1 }, + { "strTrue", "True" }, + { "strFalse", "false" }, + { "strOne", "1" }, + { "strZero", "0" }, + { "nullVal", null! } + }; + + Assert.True(dict.GetBool("boolTrue")); + Assert.False(dict.GetBool("boolFalse")); + Assert.True(dict.GetBool("intOne")); + Assert.False(dict.GetBool("intZero")); + Assert.True(dict.GetBool("longOne")); + Assert.False(dict.GetBool("longZero")); + Assert.True(dict.GetBool("byteOne")); + Assert.True(dict.GetBool("strTrue")); + Assert.False(dict.GetBool("strFalse")); + Assert.True(dict.GetBool("strOne")); + Assert.False(dict.GetBool("strZero")); + Assert.False(dict.GetBool("nullVal")); + Assert.False(dict.GetBool("missingKey")); + Assert.True(dict.GetBool("missingKey", defaultValue: true)); + } + + [Fact] + public void ClientDictionaryExtensions_GetInt_and_GetLong_handle_conversions() + { + var dict = new Dictionary + { + { "intVal", 42 }, + { "longVal", 9876543210L }, + { "doubleVal", 123.45 }, + { "strInt", "500" }, + { "byteVal", (byte)7 } + }; + + Assert.Equal(42, dict.GetInt("intVal")); + Assert.Equal(500, dict.GetInt("strInt")); + Assert.Equal(7, dict.GetInt("byteVal")); + Assert.Equal(123, dict.GetInt("doubleVal")); + Assert.Equal(0, dict.GetInt("missingKey")); + Assert.Equal(99, dict.GetInt("missingKey", 99)); + + Assert.Equal(9876543210L, dict.GetLong("longVal")); + Assert.Equal(42L, dict.GetLong("intVal")); + Assert.Equal(500L, dict.GetLong("strInt")); + Assert.Equal(0L, dict.GetLong("missingKey")); + Assert.Equal(100L, dict.GetLong("missingKey", 100L)); + } + + [Fact] + public void WelcomeInfo_parses_welcome_payload_with_genxy_types() + { + // GenXY serializes booleans as ints + var data = new Dictionary + { + { k.worldName, "Perpetuum Test World" }, + { "version", "p36.5-custom" }, + { k.OSTime, DateTime.UtcNow }, + { "steamLoginEnabled", 1 }, // int from Genxy + { "resourceServerURL", "http://assets.local:1337" }, + { "dev", 1 } // int from Genxy + }; + + var welcome = WelcomeInfo.FromDictionary(data); + + Assert.Equal("Perpetuum Test World", welcome.WorldName); + Assert.Equal("p36.5-custom", welcome.Version); + Assert.NotNull(welcome.OSTime); + Assert.True(welcome.SteamLoginEnabled); + Assert.Equal("http://assets.local:1337", welcome.ResourceServerUrl); + Assert.True(welcome.IsDev); + } + + [Fact] + public void AccountInfo_parses_sign_in_payload_with_genxy_types() + { + var validUntil = DateTime.UtcNow.AddDays(30); + var data = new Dictionary + { + { k.accountID, 42 }, + { k.email, "commander@syndicate.org" }, + { k.accLevel, (int)AccessLevel.gameAdmin }, + { k.credit, 5000000L }, // could be long from Genxy + { k.isSubscriber, 1 }, // int from Genxy + { k.emailConfirmed, 1 }, // int from Genxy + { k.validUntil, validUntil } + }; + + var account = AccountInfo.FromDictionary(data); + + Assert.Equal(42, account.AccountId); + Assert.Equal("commander@syndicate.org", account.Email); + Assert.Equal(AccessLevel.gameAdmin, account.AccessLevel); + Assert.Equal(5000000, account.Credit); + Assert.True(account.IsSubscriber); + Assert.True(account.EmailConfirmed); + Assert.Equal(validUntil, account.ValidUntil); + } + + [Fact] + public void CharacterListResult_parses_nested_characters_dictionary() + { + var char0 = new Dictionary + { + { k.characterID, 101 }, + { k.rootEID, 1000101L }, + { k.nick, "AlphaPilot" }, + { k.docked, 1 }, // int 1 from Genxy + { k.baseEID, 5001L }, + { k.baseName, "The Syndicate Outpost" }, + { k.credit, 250000L } + }; + + var char1 = new Dictionary + { + { k.characterID, 102 }, + { k.rootEID, 1000102L }, + { k.nick, "BetaScout" }, + { k.docked, 0 }, // int 0 from Genxy + { k.zoneID, 3 }, + { k.credit, 120000L } + }; + + var charactersDict = new Dictionary + { + { "c0", char0 }, + { "c1", char1 } + }; + + var innerResult = new Dictionary + { + { "characters", charactersDict }, + { "extensionPoints", 75000 } + }; + + var rootData = new Dictionary + { + { k.result, innerResult } + }; + + var listResult = CharacterListResult.FromDictionary(rootData); + + Assert.Equal(75000, listResult.ExtensionPoints); + Assert.Equal(2, listResult.Characters.Count); + + var first = listResult.Characters[0]; + Assert.Equal(101, first.CharacterId); + Assert.Equal("AlphaPilot", first.Nick); + Assert.True(first.IsDocked); + Assert.Equal("The Syndicate Outpost", first.BaseName); + + var second = listResult.Characters[1]; + Assert.Equal(102, second.CharacterId); + Assert.Equal("BetaScout", second.Nick); + Assert.False(second.IsDocked); + Assert.Equal(3, second.ZoneId); + } + + [Fact] + public void CharacterSelectResult_parses_docked_and_zone_results() + { + var dockedData = new Dictionary + { + { k.characterID, 101 }, + { k.rootEID, 1000101L }, + { k.corporationEID, 20001L }, + { k.allianceEID, 30001L } + }; + + var dockedRes = CharacterSelectResult.FromDictionary(dockedData); + Assert.Equal(101, dockedRes.CharacterId); + Assert.Equal(1000101L, dockedRes.RootEid); + Assert.Equal(20001L, dockedRes.CorporationEid); + Assert.Equal(30001L, dockedRes.AllianceEid); + Assert.True(dockedRes.IsDocked); + Assert.Null(dockedRes.ZoneData); + + var zoneData = new Dictionary + { + { k.characterID, 102 }, + { k.rootEID, 1000102L }, + { k.corporationEID, 20002L }, + { k.zone, new Dictionary { { "plugin", "zone_1" } } } + }; + + var zoneRes = CharacterSelectResult.FromDictionary(zoneData); + Assert.Equal(102, zoneRes.CharacterId); + Assert.False(zoneRes.IsDocked); + Assert.NotNull(zoneRes.ZoneData); + } + + [Fact] + public void ChannelInfo_and_ChatMessage_parse_correctly() + { + var channelData = new Dictionary + { + { k.name, "General" }, + { k.type, 0 }, + { k.password, 0 }, + { k.count, 15 }, + { k.topic, "Welcome to OpenPerpetuum" } + }; + + var channel = ChannelInfo.FromDictionary(channelData); + Assert.Equal("General", channel.Name); + Assert.Equal(0, channel.Type); + Assert.Equal("Public", channel.TypeName); + Assert.False(channel.HasPassword); + Assert.Equal(15, channel.MemberCount); + Assert.Equal("Welcome to OpenPerpetuum", channel.Topic); + + var notificationData = new Dictionary + { + { k.channel, "General" }, + { k.command, 6 }, // ChannelNotify.Message + { k.data, new Dictionary + { + { k.sender, 501 }, + { k.message, "Greetings from zone 0!" } + } + } + }; + + var chat = ChatMessage.FromNotification(notificationData); + Assert.NotNull(chat); + Assert.Equal("General", chat.ChannelName); + Assert.Equal(501, chat.SenderId); + Assert.Equal("Greetings from zone 0!", chat.Message); + } + + [Fact] + public void GenXY_serialization_deserialization_models_roundtrip() + { + // Simulate how GenxyWriter encodes server messages + var originalDict = new Dictionary + { + { k.accountID, 123 }, + { k.email, "test@openperpetuum.com" }, + { k.accLevel, (int)AccessLevel.normal }, + { k.credit, 2000000 }, + { k.isSubscriber, 1 }, // GenXY writes boolean as int + { k.emailConfirmed, 1 } + }; + + var genxyString = GenxyConverter.Serialize(originalDict); + var deserializedDict = GenxyConverter.Deserialize(genxyString); + + var account = AccountInfo.FromDictionary(deserializedDict); + Assert.Equal(123, account.AccountId); + Assert.Equal("test@openperpetuum.com", account.Email); + Assert.Equal(AccessLevel.normal, account.AccessLevel); + Assert.Equal(2000000, account.Credit); + Assert.True(account.IsSubscriber); + Assert.True(account.EmailConfirmed); + } + + [Fact] + public void Error_checking_throws_PerpetuumClientException_on_server_error() + { + var errorData = new Dictionary + { + { k.rErr, (int)ErrorCodes.NoSuchUser } + }; + + var msg = new Message(Commands.SignIn, errorData); + + var ex = Assert.Throws(() => PerpetuumClient.CheckResponseError(msg)); + Assert.Equal(ErrorCodes.NoSuchUser, ex.ErrorCode); + Assert.Contains("Invalid username/email or password", ex.Message); + } + + [Fact] + public void Commands_lookup_works_for_registered_commands() + { + var cmd = Commands.GetCommandByText("signIn"); + Assert.NotNull(cmd); + Assert.Equal("signIn", cmd.Text); + + var welcomeCmd = Commands.GetCommandByText("welcome"); + Assert.NotNull(welcomeCmd); + Assert.Equal("welcome", welcomeCmd.Text); + + var talkCmd = Commands.GetCommandByText("channelTalk"); + Assert.NotNull(talkCmd); + Assert.Equal("channelTalk", talkCmd.Text); + } + + [Fact] + public void Full_handshake_and_uncompressed_message_codec_simulation() + { + // 1. Client generates 40-byte RC4 key and encrypts with RSA + byte[] clientStreamKey = FastRandom.NextBytes(40); + var clientRc4 = new Rc4(clientStreamKey); + + byte[] rsaEncrypted = Rsa.Encrypt(clientStreamKey)!; + Assert.NotNull(rsaEncrypted); + + // 2. Server decrypts RSA and creates server RC4 + byte[] decryptedKey = Rsa.Decrypt(rsaEncrypted)!; + Assert.NotNull(decryptedKey); + Assert.Equal(clientStreamKey, decryptedKey); + var serverRc4 = new Rc4(decryptedKey); + + // 3. Server sends Welcome message (uncompressed) + var welcomeData = new Dictionary + { + { k.worldName, "Perpetuum" }, + { "version", "p36.5" }, + { "steamLoginEnabled", 0 } + }; + var serverWelcomeMsg = new Message(Commands.Welcome, welcomeData) { Sender = "Relay" }; + byte[] serverRawBytes = serverWelcomeMsg.ToBytes(); + + // Server OnProcessOutputRawData logic: + byte compressionLevel = 0; + var serverOutPacket = new byte[serverRawBytes.Length + 3]; + Buffer.BlockCopy(serverRawBytes, 0, serverOutPacket, 3, serverRawBytes.Length); + serverRc4.Encrypt(serverOutPacket); + var serverWireData = new byte[serverOutPacket.Length + 1]; + Buffer.BlockCopy(serverOutPacket, 0, serverWireData, 1, serverOutPacket.Length); + serverWireData[0] = compressionLevel; + + // 4. Client receives wire data and decodes: + clientRc4.Decrypt(serverWireData, 1, serverWireData.Length - 1); + int dataOffset = 4; + if (serverWireData[0] == 2) + { + serverWireData = GZip.Decompress(serverWireData, 5); + dataOffset--; + } + string clientReceivedText = Encoding.UTF8.GetString(serverWireData, dataOffset, serverWireData.Length - dataOffset); + var clientParsedMessage = Message.Parse(clientReceivedText); + + Assert.Equal(Commands.Welcome.Text, clientParsedMessage.Command.Text); + var welcome = WelcomeInfo.FromDictionary(clientParsedMessage.Data); + Assert.Equal("Perpetuum", welcome.WorldName); + Assert.Equal("p36.5", welcome.Version); + Assert.False(welcome.SteamLoginEnabled); + } + + [Fact] + public void Full_compressed_message_codec_simulation() + { + // Setup keys + byte[] streamKey = FastRandom.NextBytes(40); + var clientRc4 = new Rc4(streamKey); + var serverRc4 = new Rc4(streamKey); + + // Server builds a large character list payload (> 1024 bytes) + var charactersDict = new Dictionary(); + for (int i = 0; i < 20; i++) + { + charactersDict.Add($"c{i}", new Dictionary + { + { k.characterID, 1000 + i }, + { k.rootEID, 5000000L + i }, + { k.nick, $"TestPilotNumber_{i}_LongNameForPadding" }, + { k.docked, 1 }, + { k.baseName, "Syndicate Primary Alpha Base Research Station" }, + { k.credit, 1000000L * (i + 1) } + }); + } + var rootData = new Dictionary + { + { k.result, new Dictionary { { "characters", charactersDict }, { "extensionPoints", 150000 } } } + }; + var serverCharListMsg = new Message(Commands.CharacterList, rootData) { Sender = "Relay" }; + byte[] serverRawBytes = serverCharListMsg.ToBytes(); + Assert.True(serverRawBytes.Length > 1024, "Test payload should exceed 1024 bytes to trigger compression"); + + // Server OnProcessOutputRawData logic: + byte compressionLevel = 0; + var serverOutPacket = new byte[serverRawBytes.Length + 3]; + Buffer.BlockCopy(serverRawBytes, 0, serverOutPacket, 3, serverRawBytes.Length); + if (serverOutPacket.Length > 1024) + { + compressionLevel = 2; + serverOutPacket = GZip.Compress(serverOutPacket); + } + serverRc4.Encrypt(serverOutPacket); + var serverWireData = new byte[serverOutPacket.Length + 1]; + Buffer.BlockCopy(serverOutPacket, 0, serverWireData, 1, serverOutPacket.Length); + serverWireData[0] = compressionLevel; + + Assert.Equal(2, serverWireData[0]); + + // Client receives wire data and decodes: + clientRc4.Decrypt(serverWireData, 1, serverWireData.Length - 1); + int dataOffset = 4; + if (serverWireData[0] == 2) + { + serverWireData = GZip.Decompress(serverWireData, 5); + dataOffset--; + } + string clientReceivedText = Encoding.UTF8.GetString(serverWireData, dataOffset, serverWireData.Length - dataOffset); + var clientParsedMessage = Message.Parse(clientReceivedText); + + Assert.Equal(Commands.CharacterList.Text, clientParsedMessage.Command.Text); + var result = CharacterListResult.FromDictionary(clientParsedMessage.Data); + Assert.Equal(150000, result.ExtensionPoints); + Assert.Equal(20, result.Characters.Count); + Assert.Equal("TestPilotNumber_0_LongNameForPadding", result.Characters[0].Nick); + Assert.Equal("TestPilotNumber_19_LongNameForPadding", result.Characters[19].Nick); + } + + [Fact] + public void StorageResult_and_StorageItemInfo_parse_correctly() + { + var defDict = new Dictionary + { + { 101, "def_robot_arkhe" }, + { 202, "def_ammo_longrange_missile_a" }, + { 303, "def_module_laser_small" } + }; + + var item1 = new Dictionary + { + { k.eid, 50001L }, + { k.definition, 101 }, + { k.quantity, 1 }, + { k.repackaged, 1 }, + { k.health, 100.0 }, + { k.volume, 50.0 }, + { k.parent, 10001L }, + { k.owner, 2001L } + }; + + var subItem = new Dictionary + { + { k.eid, 50003L }, + { k.definition, 303 }, + { k.name, "Custom Laser" }, + { k.quantity, 2 }, + { k.repackaged, 0 }, + { k.health, 98.5 }, + { k.volume, 4.0 }, + { k.parent, 50002L }, + { k.owner, 2001L } + }; + + var item2 = new Dictionary + { + { k.eid, 50002L }, + { k.definition, 202 }, + { k.quantity, 500 }, + { k.repackaged, 1 }, + { k.health, 100.0 }, + { k.volume, 10.5 }, + { k.parent, 10001L }, + { k.owner, 2001L }, + { k.items, new Dictionary { { "c0", subItem } } } + }; + + var storagePayload = new Dictionary + { + { k.eid, 10001L }, + { k.definition, 999 }, + { k.baseEID, 777L }, + { k.items, new Dictionary + { + { "c0", item1 }, + { "c1", item2 } + } + } + }; + + var storage = StorageResult.FromDictionary(storagePayload, defDict); + + Assert.Equal(10001L, storage.ContainerEid); + Assert.Equal(999, storage.ContainerDefinition); + Assert.Equal(777L, storage.BaseEid); + Assert.Equal(2, storage.Items.Count); + Assert.Equal(501, storage.TotalItemCount); + Assert.Equal(60.5, storage.TotalVolume); + + var first = storage.Items[0]; + Assert.Equal(50001L, first.Eid); + Assert.Equal(101, first.Definition); + Assert.Equal("def_robot_arkhe", first.DefinitionName); + Assert.Equal("Robot Arkhe", first.DisplayName); + Assert.Equal(1, first.Quantity); + Assert.True(first.IsRepackaged); + Assert.Equal(50.0, first.Volume); + + var second = storage.Items[1]; + Assert.Equal(50002L, second.Eid); + Assert.Equal("def_ammo_longrange_missile_a", second.DefinitionName); + Assert.Equal("Ammo Longrange Missile A", second.DisplayName); + Assert.Equal(500, second.Quantity); + Assert.Single(second.Items); + + var child = second.Items[0]; + Assert.Equal(50003L, child.Eid); + Assert.Equal("Custom Laser", child.DisplayName); + Assert.Equal(2, child.Quantity); + Assert.False(child.IsRepackaged); + Assert.Equal(98.5, child.Health); + } + + [Fact] + public void StorageItemInfo_FormatDefinitionName_handles_various_formats() + { + Assert.Equal("Robot Arkhe", StorageItemInfo.FormatDefinitionName("def_robot_arkhe")); + Assert.Equal("Ammo Longrange Missile A", StorageItemInfo.FormatDefinitionName("def_ammo_longrange_missile_a")); + Assert.Equal("Laser Small", StorageItemInfo.FormatDefinitionName("laser_small")); + Assert.Equal(string.Empty, StorageItemInfo.FormatDefinitionName("")); + } + + [Fact] + public void CommandLineOptions_parses_storage_flags() + { + var opt1 = CommandLineOptions.Parse(new[] { "--storage" }); + Assert.True(opt1.ListStorage); + + var opt2 = CommandLineOptions.Parse(new[] { "-s" }); + Assert.True(opt2.ListStorage); + + var opt3 = CommandLineOptions.Parse(new[] { "--hangar" }); + Assert.True(opt3.ListStorage); + + var opt4 = CommandLineOptions.Parse(new[] { "--inventory" }); + Assert.True(opt4.ListStorage); + } + } +}