diff --git a/src/AcDream.Headless/Configuration/HeadlessCommandLine.cs b/src/AcDream.Headless/Configuration/HeadlessCommandLine.cs new file mode 100644 index 00000000..ad60c815 --- /dev/null +++ b/src/AcDream.Headless/Configuration/HeadlessCommandLine.cs @@ -0,0 +1,83 @@ +namespace AcDream.Headless.Configuration; + +internal sealed record HeadlessCommandLine( + string Command, + string ConfigurationPath, + HeadlessPathOverrides Paths) +{ + internal static HeadlessCommandLine Parse( + IReadOnlyList arguments) + { + ArgumentNullException.ThrowIfNull(arguments); + if (arguments.Count < 3) + throw new HeadlessCommandLineException("Missing command options."); + + string command = arguments[0]; + if (command is not ("validate" or "run")) + throw new HeadlessCommandLineException("Unknown command."); + + string? configurationPath = null; + string? configDirectory = null; + string? dataDirectory = null; + string? cacheDirectory = null; + for (int index = 1; index < arguments.Count; index += 2) + { + if (index + 1 >= arguments.Count) + { + throw new HeadlessCommandLineException( + "Every command option requires a value."); + } + + string name = arguments[index]; + string value = arguments[index + 1]; + if (string.IsNullOrWhiteSpace(value)) + { + throw new HeadlessCommandLineException( + "Command option values cannot be empty."); + } + + switch (name) + { + case "--config": + SetOnce(ref configurationPath, value); + break; + case "--config-dir": + SetOnce(ref configDirectory, value); + break; + case "--data-dir": + SetOnce(ref dataDirectory, value); + break; + case "--cache-dir": + SetOnce(ref cacheDirectory, value); + break; + default: + throw new HeadlessCommandLineException( + "Unknown command option."); + } + } + + if (configurationPath is null) + { + throw new HeadlessCommandLineException( + "The --config option is required."); + } + + return new HeadlessCommandLine( + command, + configurationPath, + new HeadlessPathOverrides( + configDirectory, + dataDirectory, + cacheDirectory)); + } + + private static void SetOnce(ref string? destination, string value) + { + if (destination is not null) + { + throw new HeadlessCommandLineException( + "Command options cannot be repeated."); + } + destination = value; + } +} diff --git a/src/AcDream.Headless/Configuration/HeadlessCommandLineException.cs b/src/AcDream.Headless/Configuration/HeadlessCommandLineException.cs new file mode 100644 index 00000000..f02775fe --- /dev/null +++ b/src/AcDream.Headless/Configuration/HeadlessCommandLineException.cs @@ -0,0 +1,9 @@ +namespace AcDream.Headless.Configuration; + +internal sealed class HeadlessCommandLineException : Exception +{ + internal HeadlessCommandLineException(string message) + : base(message) + { + } +} diff --git a/src/AcDream.Headless/Configuration/HeadlessConfiguration.cs b/src/AcDream.Headless/Configuration/HeadlessConfiguration.cs index 0d5858b9..752177f7 100644 --- a/src/AcDream.Headless/Configuration/HeadlessConfiguration.cs +++ b/src/AcDream.Headless/Configuration/HeadlessConfiguration.cs @@ -7,12 +7,73 @@ internal sealed class HeadlessConfiguration [JsonRequired] public int Version { get; init; } + public HeadlessProcessSettings Process { get; init; } = new(); + [JsonRequired] public List Sessions { get; init; } = []; } +internal sealed class HeadlessProcessSettings +{ + public HeadlessPathOverrides Paths { get; init; } = new(); +} + internal sealed class HeadlessSessionDescriptor { [JsonRequired] public string Id { get; init; } = string.Empty; + + [JsonRequired] + public HeadlessEndpointDescriptor Endpoint { get; init; } = new(); + + [JsonRequired] + public string Account { get; init; } = string.Empty; + + [JsonRequired] + public HeadlessCharacterSelector Character { get; init; } = new(); + + [JsonRequired] + public HeadlessBotPolicyDescriptor Policy { get; init; } = new(); + + [JsonRequired] + public HeadlessCredentialReference Credential { get; init; } = new(); +} + +internal sealed class HeadlessEndpointDescriptor +{ + [JsonRequired] + public string Host { get; init; } = string.Empty; + + [JsonRequired] + public int Port { get; init; } +} + +internal sealed class HeadlessCharacterSelector +{ + public int? Index { get; init; } + public uint? Id { get; init; } + public string? Name { get; init; } +} + +internal sealed class HeadlessBotPolicyDescriptor +{ + [JsonRequired] + public string Id { get; init; } = string.Empty; +} + +[JsonConverter(typeof(JsonStringEnumConverter))] +internal enum HeadlessCredentialProviderKind +{ + Environment, + StandardInput, + File, +} + +internal sealed class HeadlessCredentialReference +{ + [JsonRequired] + public HeadlessCredentialProviderKind Provider { get; init; } + + [JsonRequired] + public string Reference { get; init; } = string.Empty; } diff --git a/src/AcDream.Headless/Configuration/HeadlessConfigurationLoader.cs b/src/AcDream.Headless/Configuration/HeadlessConfigurationLoader.cs index 0141712e..20582f13 100644 --- a/src/AcDream.Headless/Configuration/HeadlessConfigurationLoader.cs +++ b/src/AcDream.Headless/Configuration/HeadlessConfigurationLoader.cs @@ -14,6 +14,12 @@ internal static class HeadlessConfigurationLoader PropertyNamingPolicy = JsonNamingPolicy.CamelCase, ReadCommentHandling = JsonCommentHandling.Disallow, UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow, + Converters = + { + new JsonStringEnumConverter( + JsonNamingPolicy.CamelCase, + allowIntegerValues: false), + }, }; internal static HeadlessConfiguration Load(string path) @@ -47,6 +53,8 @@ internal static class HeadlessConfigurationLoader } var sessionIds = new HashSet(StringComparer.Ordinal); + var credentialReferences = new HashSet( + StringComparer.Ordinal); foreach (HeadlessSessionDescriptor? session in configuration.Sessions) { if (session is null @@ -61,8 +69,66 @@ internal static class HeadlessConfigurationLoader throw new HeadlessConfigurationException( $"Duplicate session id '{session.Id}'."); } + + ValidateSession(session); + string credentialKey = + $"{session.Credential.Provider}:{session.Credential.Reference}"; + if (!credentialReferences.Add(credentialKey)) + { + throw new HeadlessConfigurationException( + $"Credential reference for session '{session.Id}' is already in use."); + } } return configuration; } + + private static void ValidateSession(HeadlessSessionDescriptor session) + { + if (session.Endpoint is null + || string.IsNullOrWhiteSpace(session.Endpoint.Host) + || session.Endpoint.Port is < 1 or > 65535) + { + throw new HeadlessConfigurationException( + $"Session '{session.Id}' requires a host and a port from 1 through 65535."); + } + + if (string.IsNullOrWhiteSpace(session.Account)) + { + throw new HeadlessConfigurationException( + $"Session '{session.Id}' requires a non-empty account."); + } + + if (session.Character is null) + { + throw new HeadlessConfigurationException( + $"Session '{session.Id}' requires a character selector."); + } + + int selectorCount = + (session.Character.Index.HasValue ? 1 : 0) + + (session.Character.Id.HasValue ? 1 : 0) + + (!string.IsNullOrWhiteSpace(session.Character.Name) ? 1 : 0); + if (selectorCount != 1 + || session.Character.Index is < 0 + || session.Character.Id == 0u) + { + throw new HeadlessConfigurationException( + $"Session '{session.Id}' character selector must specify exactly one valid index, id, or name."); + } + + if (session.Policy is null + || string.IsNullOrWhiteSpace(session.Policy.Id)) + { + throw new HeadlessConfigurationException( + $"Session '{session.Id}' requires a non-empty policy id."); + } + + if (session.Credential is null + || string.IsNullOrWhiteSpace(session.Credential.Reference)) + { + throw new HeadlessConfigurationException( + $"Session '{session.Id}' requires a credential reference."); + } + } } diff --git a/src/AcDream.Headless/Configuration/HeadlessPathOverrides.cs b/src/AcDream.Headless/Configuration/HeadlessPathOverrides.cs new file mode 100644 index 00000000..c83ce278 --- /dev/null +++ b/src/AcDream.Headless/Configuration/HeadlessPathOverrides.cs @@ -0,0 +1,13 @@ +namespace AcDream.Headless.Configuration; + +internal sealed record HeadlessPathOverrides( + string? ConfigDirectory = null, + string? DataDirectory = null, + string? CacheDirectory = null) +{ + internal HeadlessPathOverrides Merge(HeadlessPathOverrides commandLine) => + new( + commandLine.ConfigDirectory ?? ConfigDirectory, + commandLine.DataDirectory ?? DataDirectory, + commandLine.CacheDirectory ?? CacheDirectory); +} diff --git a/src/AcDream.Headless/Credentials/HeadlessCredentialException.cs b/src/AcDream.Headless/Credentials/HeadlessCredentialException.cs new file mode 100644 index 00000000..01724b9a --- /dev/null +++ b/src/AcDream.Headless/Credentials/HeadlessCredentialException.cs @@ -0,0 +1,16 @@ +namespace AcDream.Headless.Credentials; + +internal sealed class HeadlessCredentialException : Exception +{ + internal HeadlessCredentialException(string message) + : base(message) + { + } + + internal HeadlessCredentialException( + string message, + Exception innerException) + : base(message, innerException) + { + } +} diff --git a/src/AcDream.Headless/Credentials/HeadlessCredentialResolver.cs b/src/AcDream.Headless/Credentials/HeadlessCredentialResolver.cs new file mode 100644 index 00000000..c348146f --- /dev/null +++ b/src/AcDream.Headless/Credentials/HeadlessCredentialResolver.cs @@ -0,0 +1,159 @@ +using AcDream.Headless.Configuration; + +namespace AcDream.Headless.Credentials; + +internal interface IHeadlessCredentialEnvironment +{ + bool IsLinux { get; } + string? GetEnvironmentVariable(string name); +} + +internal sealed class HeadlessCredentialEnvironment + : IHeadlessCredentialEnvironment +{ + internal static HeadlessCredentialEnvironment Instance { get; } = new(); + + private HeadlessCredentialEnvironment() + { + } + + public bool IsLinux => OperatingSystem.IsLinux(); + + public string? GetEnvironmentVariable(string name) => + Environment.GetEnvironmentVariable(name); +} + +internal sealed class HeadlessCredentialResolver +{ + private const UnixFileMode NonUserPermissionMask = + UnixFileMode.GroupRead + | UnixFileMode.GroupWrite + | UnixFileMode.GroupExecute + | UnixFileMode.OtherRead + | UnixFileMode.OtherWrite + | UnixFileMode.OtherExecute; + + private readonly TextReader _standardInput; + private readonly IHeadlessCredentialEnvironment _environment; + private readonly string _credentialBaseDirectory; + + internal HeadlessCredentialResolver( + TextReader standardInput, + string credentialBaseDirectory, + IHeadlessCredentialEnvironment? environment = null) + { + _standardInput = standardInput + ?? throw new ArgumentNullException(nameof(standardInput)); + ArgumentException.ThrowIfNullOrWhiteSpace(credentialBaseDirectory); + _credentialBaseDirectory = Path.GetFullPath( + credentialBaseDirectory); + _environment = + environment ?? HeadlessCredentialEnvironment.Instance; + } + + internal HeadlessCredentialSecret Resolve( + string sessionId, + HeadlessCredentialReference credential) + { + ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); + ArgumentNullException.ThrowIfNull(credential); + + string value; + try + { + value = credential.Provider switch + { + HeadlessCredentialProviderKind.Environment => + ResolveEnvironment(credential.Reference), + HeadlessCredentialProviderKind.StandardInput => + ResolveStandardInput(credential.Reference), + HeadlessCredentialProviderKind.File => + ResolveFile(credential.Reference), + _ => throw new HeadlessCredentialException( + $"Session '{sessionId}' uses an unsupported credential provider."), + }; + } + catch (HeadlessCredentialException) + { + throw; + } + catch (Exception error) + when (error is IOException + or UnauthorizedAccessException + or ArgumentException + or NotSupportedException) + { + throw new HeadlessCredentialException( + $"Credential '{credential.Reference}' for session '{sessionId}' could not be resolved.", + error); + } + + try + { + return new HeadlessCredentialSecret( + credential.Reference, + value.AsSpan()); + } + finally + { + // The BCL returns immutable strings from environment, TextReader, + // and File APIs. Do not retain another copy in the resolver; the + // erasable char[] owner becomes the sole explicit retained copy. + value = string.Empty; + } + } + + private string ResolveEnvironment(string reference) + { + string? value = _environment.GetEnvironmentVariable(reference); + if (string.IsNullOrEmpty(value)) + { + throw new HeadlessCredentialException( + $"Credential environment reference '{reference}' is unavailable."); + } + return value; + } + + private string ResolveStandardInput(string reference) + { + string? value = _standardInput.ReadLine(); + if (string.IsNullOrEmpty(value)) + { + throw new HeadlessCredentialException( + $"Credential standard-input reference '{reference}' is unavailable."); + } + return value; + } + + private string ResolveFile(string reference) + { + string path = Path.GetFullPath( + reference, + _credentialBaseDirectory); + var file = new FileInfo(path); + if (file.LinkTarget is not null) + { + throw new HeadlessCredentialException( + $"Credential file reference '{reference}' cannot be a symbolic link."); + } + + if (OperatingSystem.IsLinux() && _environment.IsLinux) + { + UnixFileMode mode = File.GetUnixFileMode(path); + if ((mode & NonUserPermissionMask) != 0 + || (mode & UnixFileMode.UserRead) == 0) + { + throw new HeadlessCredentialException( + $"Credential file reference '{reference}' must be readable only by its owner."); + } + } + + string value = File.ReadAllText(path).TrimEnd('\r', '\n'); + if (value.Length == 0) + { + throw new HeadlessCredentialException( + $"Credential file reference '{reference}' is empty."); + } + return value; + } +} diff --git a/src/AcDream.Headless/Credentials/HeadlessCredentialSecret.cs b/src/AcDream.Headless/Credentials/HeadlessCredentialSecret.cs new file mode 100644 index 00000000..5eeab79f --- /dev/null +++ b/src/AcDream.Headless/Credentials/HeadlessCredentialSecret.cs @@ -0,0 +1,50 @@ +using System.Security.Cryptography; + +namespace AcDream.Headless.Credentials; + +/// +/// Retains a resolved credential in erasable memory. The network boundary +/// still requires one short-lived immutable string; callers must not retain +/// that value beyond construction of the connect request. +/// +internal sealed class HeadlessCredentialSecret : IDisposable +{ + private char[]? _buffer; + + internal HeadlessCredentialSecret( + string referenceId, + ReadOnlySpan value) + { + ArgumentException.ThrowIfNullOrWhiteSpace(referenceId); + if (value.IsEmpty) + { + throw new HeadlessCredentialException( + $"Credential '{referenceId}' resolved to an empty secret."); + } + + ReferenceId = referenceId; + _buffer = value.ToArray(); + } + + internal string ReferenceId { get; } + internal bool IsDisposed => _buffer is null; + + internal string Reveal() + { + ObjectDisposedException.ThrowIf(_buffer is null, this); + return new string(_buffer); + } + + public void Dispose() + { + char[]? buffer = Interlocked.Exchange(ref _buffer, null); + if (buffer is null) + return; + CryptographicOperations.ZeroMemory( + System.Runtime.InteropServices.MemoryMarshal.AsBytes( + buffer.AsSpan())); + } + + public override string ToString() => + $"[redacted:{ReferenceId}]"; +} diff --git a/src/AcDream.Headless/Diagnostics/HeadlessDiagnosticWriter.cs b/src/AcDream.Headless/Diagnostics/HeadlessDiagnosticWriter.cs new file mode 100644 index 00000000..9b268697 --- /dev/null +++ b/src/AcDream.Headless/Diagnostics/HeadlessDiagnosticWriter.cs @@ -0,0 +1,80 @@ +using System.Diagnostics; +using System.Text.Json; +using AcDream.Runtime; + +namespace AcDream.Headless.Diagnostics; + +internal sealed class HeadlessDiagnosticWriter +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }; + + private readonly TextWriter _output; + + internal HeadlessDiagnosticWriter(TextWriter output) + { + _output = output ?? throw new ArgumentNullException(nameof(output)); + } + + internal void Lifecycle( + string sessionId, + string state, + GameRuntime? runtime = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); + ArgumentException.ThrowIfNullOrWhiteSpace(state); + using Process process = Process.GetCurrentProcess(); + RuntimeStateCheckpoint? checkpoint = + runtime?.CaptureCheckpoint(); + Write(new + { + kind = "lifecycle", + sessionId, + state, + generation = checkpoint?.Generation.Value ?? 0UL, + lifecycle = checkpoint?.Lifecycle.ToString() ?? "none", + entities = checkpoint?.EntityCount ?? 0, + inventoryObjects = + checkpoint?.InventoryObjectCount ?? 0, + workingSetBytes = process.WorkingSet64, + privateBytes = process.PrivateMemorySize64, + }); + } + + internal void Failure( + string sessionId, + string phase, + Exception error) + { + ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); + ArgumentException.ThrowIfNullOrWhiteSpace(phase); + ArgumentNullException.ThrowIfNull(error); + Write(new + { + kind = "failure", + sessionId, + phase, + errorType = error.GetType().FullName, + }); + } + + internal void Message( + string sessionId, + string eventName, + ulong generation = 0UL) => + Write(new + { + kind = "event", + sessionId, + eventName, + generation, + }); + + private void Write(T value) + { + _output.WriteLine(JsonSerializer.Serialize(value, JsonOptions)); + _output.Flush(); + } +} diff --git a/src/AcDream.Headless/HeadlessEntryPoint.cs b/src/AcDream.Headless/HeadlessEntryPoint.cs index 5f3135b2..590b09aa 100644 --- a/src/AcDream.Headless/HeadlessEntryPoint.cs +++ b/src/AcDream.Headless/HeadlessEntryPoint.cs @@ -1,4 +1,7 @@ using AcDream.Headless.Configuration; +using AcDream.Headless.Credentials; +using AcDream.Headless.Hosting; +using AcDream.Headless.Platform; namespace AcDream.Headless; @@ -10,10 +13,17 @@ internal static class HeadlessEntryPoint Usage: acdream-headless --help - acdream-headless validate --config + acdream-headless validate --config [path overrides] + acdream-headless run --config [path overrides] Commands: validate Validate a versioned headless configuration without connecting. + run Run exactly one configured no-window session. + + Path overrides: + --config-dir + --data-dir + --cache-dir Secrets are never accepted on the command line. """; @@ -21,9 +31,23 @@ internal static class HeadlessEntryPoint internal static int Run( IReadOnlyList arguments, TextWriter output, - TextWriter error) + TextWriter error) => + Run( + arguments, + TextReader.Null, + output, + error, + CancellationToken.None); + + internal static int Run( + IReadOnlyList arguments, + TextReader standardInput, + TextWriter output, + TextWriter error, + CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(arguments); + ArgumentNullException.ThrowIfNull(standardInput); ArgumentNullException.ThrowIfNull(output); ArgumentNullException.ThrowIfNull(error); @@ -34,46 +58,67 @@ internal static class HeadlessEntryPoint return (int)HeadlessExitCode.Success; } - if (!string.Equals( - arguments[0], - "validate", - StringComparison.Ordinal)) - { - error.WriteLine("Unknown command. Run --help for usage."); - return (int)HeadlessExitCode.UsageError; - } - - if (arguments.Count != 3 - || !string.Equals( - arguments[1], - "--config", - StringComparison.Ordinal)) - { - error.WriteLine( - "validate requires exactly: --config "); - return (int)HeadlessExitCode.UsageError; - } - try { + HeadlessCommandLine commandLine = + HeadlessCommandLine.Parse(arguments); HeadlessConfiguration configuration = - HeadlessConfigurationLoader.Load(arguments[2]); + HeadlessConfigurationLoader.Load( + commandLine.ConfigurationPath); + HeadlessPathOverrides configuredPaths = + configuration.Process?.Paths + ?? new HeadlessPathOverrides(); + HeadlessPathSet paths = HeadlessPathSet.Resolve( + configuredPaths.Merge(commandLine.Paths)); + if (commandLine.Command == "run") + { + using var host = new HeadlessProcessHost( + configuration, + paths, + standardInput, + output); + return (int)host.RunAsync(cancellationToken) + .GetAwaiter() + .GetResult(); + } + output.WriteLine( $"Configuration valid: version {configuration.Version}, " + $"{configuration.Sessions.Count} session(s)."); return (int)HeadlessExitCode.Success; } + catch (HeadlessCommandLineException) + { + error.WriteLine("Invalid command. Run --help for usage."); + return (int)HeadlessExitCode.UsageError; + } + catch (System.Text.Json.JsonException) + { + error.WriteLine( + "Configuration invalid: the JSON document is invalid."); + return (int)HeadlessExitCode.ConfigurationError; + } catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException - or System.Text.Json.JsonException or HeadlessConfigurationException) { error.WriteLine($"Configuration invalid: {exception.Message}"); return (int)HeadlessExitCode.ConfigurationError; } + catch (HeadlessCredentialException exception) + { + error.WriteLine($"Credential unavailable: {exception.Message}"); + return (int)HeadlessExitCode.CredentialError; + } + catch (Exception exception) + { + error.WriteLine( + $"Headless runtime failed: {exception.GetType().FullName}"); + return (int)HeadlessExitCode.RuntimeError; + } } private static bool IsHelp(string argument) => diff --git a/src/AcDream.Headless/HeadlessExitCode.cs b/src/AcDream.Headless/HeadlessExitCode.cs index 0e199e8f..9cd07503 100644 --- a/src/AcDream.Headless/HeadlessExitCode.cs +++ b/src/AcDream.Headless/HeadlessExitCode.cs @@ -5,4 +5,7 @@ internal enum HeadlessExitCode Success = 0, UsageError = 2, ConfigurationError = 3, + CredentialError = 4, + ConnectionError = 5, + RuntimeError = 6, } diff --git a/src/AcDream.Headless/Hosting/HeadlessGameplayOperations.cs b/src/AcDream.Headless/Hosting/HeadlessGameplayOperations.cs new file mode 100644 index 00000000..2a2633d1 --- /dev/null +++ b/src/AcDream.Headless/Hosting/HeadlessGameplayOperations.cs @@ -0,0 +1,80 @@ +using AcDream.Core.Combat; +using AcDream.Core.Items; +using AcDream.Core.Spells; +using AcDream.Runtime; +using AcDream.Runtime.Gameplay; + +namespace AcDream.Headless.Hosting; + +/// +/// K1 construction seam for Runtime gameplay owners. K2 replaces the +/// unsupported outbound methods through the complete typed command adapter; +/// K1 never bypasses that adapter to issue gameplay actions. +/// +internal sealed class HeadlessGameplayOperations + : IRuntimeCombatAttackOperations, + IRuntimeCombatTargetOperations, + IRuntimeCombatModeOperations, + IRuntimeSpellCastOperations +{ + private GameRuntime? _runtime; + + internal void Bind(GameRuntime runtime) => + _runtime = runtime + ?? throw new ArgumentNullException(nameof(runtime)); + + public bool CanStartAttack() => false; + public void PrepareAttackRequest() + { + } + + public bool SendAttack(AttackHeight height, float power) => false; + public void SendCancelAttack() + { + } + + public bool IsDualWield => false; + public bool PlayerReadyForAttack => false; + public bool AutoRepeatAttack => false; + public bool AutoTarget => false; + public uint? SelectClosestTarget() => null; + public bool IsInWorld => _runtime?.Session.IsInWorld == true; + public IReadOnlyList GetOrderedEquipment() => []; + public void NotifyExplicitCombatModeRequest() + { + } + + public void SendChangeCombatMode(CombatMode mode) + { + } + + public uint LocalPlayerId => + _runtime?.PlayerIdentity.ServerGuid ?? 0u; + public bool CanSend => false; + public bool HasRequiredComponents(uint spellId) => false; + + public bool IsTargetCompatible( + uint targetId, + SpellMetadata spell, + bool showMessage) => false; + + public void StopCompletely() + { + } + + public void SendUntargeted(uint spellId) + { + } + + public void SendTargeted(uint targetId, uint spellId) + { + } + + public void DisplayMessage(string message) + { + } + + public void IncrementBusy() + { + } +} diff --git a/src/AcDream.Headless/Hosting/HeadlessGenerationResetHost.cs b/src/AcDream.Headless/Hosting/HeadlessGenerationResetHost.cs new file mode 100644 index 00000000..b6e0cf4e --- /dev/null +++ b/src/AcDream.Headless/Hosting/HeadlessGenerationResetHost.cs @@ -0,0 +1,25 @@ +using AcDream.Runtime; +using AcDream.Runtime.Entities; + +namespace AcDream.Headless.Hosting; + +/// +/// A direct host owns no graphical projections, but still acknowledges every +/// exact canonical retirement edge required by Runtime generation reset. +/// +internal sealed class HeadlessGenerationResetHost + : IRuntimeGenerationResetHost +{ + public void RetireEntityProjection(RuntimeEntityRecord entity) + { + ArgumentNullException.ThrowIfNull(entity); + } + + public void DrainEntityProjectionBoundary() + { + } + + public void CompleteEntityProjectionRetirement() + { + } +} diff --git a/src/AcDream.Headless/Hosting/HeadlessProcessHost.cs b/src/AcDream.Headless/Hosting/HeadlessProcessHost.cs new file mode 100644 index 00000000..04a4c64e --- /dev/null +++ b/src/AcDream.Headless/Hosting/HeadlessProcessHost.cs @@ -0,0 +1,115 @@ +using System.Diagnostics; +using AcDream.Headless.Configuration; +using AcDream.Headless.Credentials; +using AcDream.Headless.Diagnostics; +using AcDream.Headless.Platform; +using AcDream.Runtime; +using AcDream.Runtime.Session; + +namespace AcDream.Headless.Hosting; + +internal sealed class HeadlessProcessHost : IDisposable +{ + private static readonly TimeSpan K1TickInterval = + TimeSpan.FromMilliseconds(15); + + private readonly HeadlessSessionHost _session; + private readonly HeadlessDiagnosticWriter _diagnostics; + private readonly string _sessionId; + private bool _disposed; + + internal HeadlessProcessHost( + HeadlessConfiguration configuration, + HeadlessPathSet paths, + TextReader standardInput, + TextWriter diagnostics, + ILiveSessionOperations? sessionOperations = null, + TimeProvider? timeProvider = null) + { + ArgumentNullException.ThrowIfNull(configuration); + ArgumentNullException.ThrowIfNull(paths); + ArgumentNullException.ThrowIfNull(standardInput); + ArgumentNullException.ThrowIfNull(diagnostics); + if (configuration.Sessions.Count != 1 + || configuration.Sessions[0] is not { } descriptor) + { + throw new HeadlessConfigurationException( + "Slice K1 run mode requires exactly one session."); + } + + _sessionId = descriptor.Id; + _diagnostics = new HeadlessDiagnosticWriter(diagnostics); + var credentials = new HeadlessCredentialResolver( + standardInput, + paths.ConfigDirectory); + HeadlessCredentialSecret secret = + credentials.Resolve(descriptor.Id, descriptor.Credential); + try + { + _session = new HeadlessSessionHost( + descriptor, + secret, + _diagnostics, + sessionOperations, + timeProvider); + } + catch + { + secret.Dispose(); + throw; + } + } + + internal HeadlessSessionHost Session => _session; + + internal async Task RunAsync( + CancellationToken cancellationToken) + { + ObjectDisposedException.ThrowIf(_disposed, this); + RuntimeSessionStartResult started = _session.Start(); + if (started.Status != RuntimeSessionStartStatus.Connected) + { + if (started.Error is { } error) + _diagnostics.Failure(_sessionId, "start", error); + return HeadlessExitCode.ConnectionError; + } + + _diagnostics.Lifecycle(_sessionId, "running", _session.Runtime); + long previous = Stopwatch.GetTimestamp(); + try + { + while (!cancellationToken.IsCancellationRequested) + { + await Task.Delay( + K1TickInterval, + cancellationToken).ConfigureAwait(false); + long current = Stopwatch.GetTimestamp(); + double deltaSeconds = + (current - previous) / (double)Stopwatch.Frequency; + previous = current; + _session.Tick(deltaSeconds); + if (_session.IsPolicyComplete) + break; + } + } + catch (OperationCanceledException) + when (cancellationToken.IsCancellationRequested) + { + } + catch (Exception error) + { + _diagnostics.Failure(_sessionId, "tick", error); + return HeadlessExitCode.RuntimeError; + } + + return HeadlessExitCode.Success; + } + + public void Dispose() + { + if (_disposed) + return; + _session.Dispose(); + _disposed = true; + } +} diff --git a/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs b/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs new file mode 100644 index 00000000..011b5ad2 --- /dev/null +++ b/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs @@ -0,0 +1,414 @@ +using System.Diagnostics; +using AcDream.Headless.Configuration; +using AcDream.Headless.Credentials; +using AcDream.Headless.Diagnostics; +using AcDream.Headless.Policies; +using AcDream.Runtime; +using AcDream.Runtime.Session; + +namespace AcDream.Headless.Hosting; + +internal sealed class HeadlessSessionHost : IDisposable +{ + private sealed class SessionCommandBridge : IRuntimeSessionCommands + { + private HeadlessSessionHost? _owner; + + internal void Bind(HeadlessSessionHost owner) + { + if (_owner is not null) + { + throw new InvalidOperationException( + "The headless session command bridge is already bound."); + } + _owner = owner + ?? throw new ArgumentNullException(nameof(owner)); + } + + public RuntimeSessionStartResult Start( + RuntimeGenerationToken expectedGeneration) => + RequireOwner().StartCore(expectedGeneration, reconnect: false); + + public RuntimeSessionStartResult Reconnect( + RuntimeGenerationToken expectedGeneration) => + RequireOwner().StartCore(expectedGeneration, reconnect: true); + + public RuntimeTeardownAcknowledgement Stop( + RuntimeGenerationToken expectedGeneration) => + RequireOwner()._liveSession.Stop(expectedGeneration); + + private HeadlessSessionHost RequireOwner() => + _owner + ?? throw new InvalidOperationException( + "The headless session command bridge is not bound."); + } + + private readonly HeadlessSessionDescriptor _descriptor; + private readonly HeadlessCredentialSecret _credential; + private readonly HeadlessDiagnosticWriter _diagnostics; + private readonly TimeSpan _reconnectQuiescence; + private readonly HeadlessGenerationResetHost _resetHost = new(); + private readonly IDisposable _hostLease; + private readonly IHeadlessBotPolicy _policy; + private readonly IDisposable _policySubscription; + private readonly LiveSessionHost _liveSession; + private int _disposeStage; + private ulong _stoppedGeneration; + private bool _disposed; + + internal HeadlessSessionHost( + HeadlessSessionDescriptor descriptor, + HeadlessCredentialSecret credential, + HeadlessDiagnosticWriter diagnostics, + ILiveSessionOperations? sessionOperations = null, + TimeProvider? timeProvider = null, + TimeSpan? reconnectQuiescence = null) + { + _descriptor = descriptor + ?? throw new ArgumentNullException(nameof(descriptor)); + _credential = credential + ?? throw new ArgumentNullException(nameof(credential)); + _diagnostics = diagnostics + ?? throw new ArgumentNullException(nameof(diagnostics)); + _reconnectQuiescence = reconnectQuiescence + ?? (sessionOperations is null + ? TimeSpan.FromMilliseconds(2500) + : TimeSpan.Zero); + if (_reconnectQuiescence < TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException( + nameof(reconnectQuiescence)); + } + + GameRuntime? runtimeRef = null; + IDisposable? hostLease = null; + IHeadlessBotPolicy? policy = null; + IDisposable? policySubscription = null; + try + { + var gameplay = new HeadlessGameplayOperations(); + var runtime = new GameRuntime(new GameRuntimeDependencies( + gameplay, + gameplay, + gameplay, + gameplay, + TimeProvider: timeProvider, + Log: message => diagnostics.Message( + descriptor.Id, + message), + SessionOperations: sessionOperations, + CombatTime: () => + runtimeRef?.Clock.SimulationTimeSeconds ?? 0d)); + runtimeRef = runtime; + gameplay.Bind(runtime); + + var bridge = new SessionCommandBridge(); + var commands = new DirectGameRuntimeCommandAdapter( + runtime, + bridge); + var liveSession = new LiveSessionHost( + runtime.Session, + new LiveSessionHostBindings( + new LiveSessionRoutingFactories( + CreateEventRoute, + commands.CreateRoute), + generation => + runtime.ResetGeneration(generation, _resetHost), + new LiveSessionSelectionBindings( + id => runtime.PlayerIdentity.ServerGuid = id, + _ => { }, + runtime.CommunicationOwner.Chat.SetLocalPlayerGuid, + _ => { }, + _ => { }, + runtime.ActionOwner.Combat.Clear), + new LiveSessionEnteredWorldBindings( + name => ActiveCharacterName = name, + () => { }, + () => { }, + _ => { }, + () => { }), + (host, port, user) => + diagnostics.Message( + descriptor.Id, + $"connecting:{host}:{port}:{user}", + runtime.Generation.Value), + () => diagnostics.Message( + descriptor.Id, + "connected", + runtime.Generation.Value))); + + Runtime = runtime; + Commands = commands; + _liveSession = liveSession; + bridge.Bind(this); + + hostLease = runtime.AcquireHostLease( + $"headless:{descriptor.Id}"); + policy = + HeadlessBotPolicyFactory.Create(descriptor.Policy.Id); + policySubscription = runtime.Subscribe(policy); + diagnostics.Lifecycle( + descriptor.Id, + "constructed", + runtime); + + _hostLease = hostLease; + _policy = policy; + _policySubscription = policySubscription; + } + catch + { + policySubscription?.Dispose(); + policy?.Dispose(); + hostLease?.Dispose(); + credential.Dispose(); + runtimeRef?.Dispose(); + throw; + } + } + + internal GameRuntime Runtime { get; } + internal DirectGameRuntimeCommandAdapter Commands { get; } + internal string ActiveCharacterName { get; private set; } = + string.Empty; + internal bool IsPolicyComplete => _policy.IsComplete; + + internal RuntimeSessionStartResult Start() => + Commands.Session.Start(Runtime.Generation); + + internal RuntimeSessionStartResult Reconnect() => + Commands.Session.Reconnect(Runtime.Generation); + + internal void Tick(double deltaSeconds) + { + ObjectDisposedException.ThrowIf(_disposed, this); + _ = Runtime.Clock.Advance(deltaSeconds); + Runtime.Session.Tick(); + Runtime.ActionOwner.CombatAttack.Tick(); + _policy.Tick(Runtime, Commands); + } + + internal RuntimeTeardownAcknowledgement Stop() => + Commands.Session.Stop(Runtime.Generation); + + public void Dispose() + { + if (_disposed) + return; + + while (!_disposed) + { + switch (_disposeStage) + { + case 0: + { + RuntimeTeardownAcknowledgement stopped = Stop(); + if (!stopped.IsComplete) + { + throw stopped.Error + ?? new InvalidOperationException( + $"Headless session '{_descriptor.Id}' did not complete teardown."); + } + _stoppedGeneration = + stopped.CurrentGeneration.Value; + _disposeStage++; + break; + } + case 1: + _diagnostics.Lifecycle( + _descriptor.Id, + "stopped", + Runtime); + _disposeStage++; + break; + case 2: + _policySubscription.Dispose(); + _disposeStage++; + break; + case 3: + _policy.Dispose(); + _disposeStage++; + break; + case 4: + _hostLease.Dispose(); + _disposeStage++; + break; + case 5: + _credential.Dispose(); + _disposeStage++; + break; + case 6: + Runtime.Dispose(); + _disposeStage++; + break; + case 7: + _diagnostics.Message( + _descriptor.Id, + "disposed", + _stoppedGeneration); + _disposeStage++; + _disposed = true; + break; + default: + throw new InvalidOperationException( + "Unknown headless session teardown stage."); + } + } + } + + private RuntimeSessionStartResult StartCore( + RuntimeGenerationToken expectedGeneration, + bool reconnect) + { + if (expectedGeneration != Runtime.Generation) + { + return new RuntimeSessionStartResult( + RuntimeSessionStartStatus.StaleGeneration, + Runtime.Generation); + } + + if (reconnect) + { + RuntimeTeardownAcknowledgement stopped = + _liveSession.Stop(expectedGeneration); + if (!stopped.IsComplete) + { + return new RuntimeSessionStartResult( + RuntimeSessionStartStatus.Failed, + stopped.CurrentGeneration, + Error: stopped.Error + ?? new InvalidOperationException( + "The prior headless session did not quiesce before reconnect.")); + } + + // ACE confirms CharacterLogOff before its account/session index + // releases the retiring socket. Starting a replacement in that + // short suffix makes ACE reject both sessions as Account In Use. + // This is a one-time connection-lifecycle wait, never a steady + // scheduler sleep; K2 moves it onto the absolute-deadline host. + if (_reconnectQuiescence > TimeSpan.Zero) + { + Task.Delay(_reconnectQuiescence) + .GetAwaiter() + .GetResult(); + } + } + + string password = _credential.Reveal(); + try + { + LiveSessionConnectOptions options = new( + Enabled: true, + _descriptor.Endpoint.Host, + _descriptor.Endpoint.Port, + _descriptor.Account, + password, + MapCharacterSelector(_descriptor.Character)); + LiveSessionStartResult result = _liveSession.Start(options); + RuntimeSessionStartResult converted = Convert(result); + if (converted.Error is { } error) + { + _diagnostics.Failure( + _descriptor.Id, + reconnect ? "reconnect" : "start", + error); + } + _diagnostics.Lifecycle( + _descriptor.Id, + reconnect ? "reconnect-result" : "start-result", + Runtime); + return converted; + } + finally + { + password = string.Empty; + } + } + + private LiveSessionEventRouter CreateEventRoute( + AcDream.Core.Net.WorldSession session) + { + var entities = new RuntimeLiveEntitySessionController( + Runtime, + session, + message => _diagnostics.Message( + _descriptor.Id, + message, + Runtime.Generation.Value)); + return new LiveSessionEventRouter( + session, + entities.CreateSink(), + new LiveEnvironmentSessionSink( + change => + _ = Runtime.EnvironmentOwner + .ApplyAdminEnvirons(change), + Runtime.EnvironmentOwner.SynchronizeFromServer), + new LiveInventorySessionBindings( + Runtime.InventoryOwner.Objects, + () => Runtime.PlayerIdentity.ServerGuid, + Runtime.InventoryOwner.Shortcuts.Load, + error => + { + Runtime.InventoryOwner.ExternalContainers + .ApplyUseDone(error); + Runtime.ActionOwner.Transactions.CompleteUse(error); + }, + Runtime.InventoryOwner.ItemMana, + Runtime.InventoryOwner.ExternalContainers, + appraisal => + Runtime.ActionOwner.Transactions + .AcceptAppraisalResponse(appraisal.Guid)), + new LiveCharacterSessionBindings( + Runtime.ActionOwner.Combat, + Runtime.CharacterOwner, + ResolveSkillFormulaBonus: null, + OnSkillsUpdated: null, + OnConfirmationRequest: null, + OnConfirmationDone: null, + ClientTime: () => + Stopwatch.GetTimestamp() + / (double)Stopwatch.Frequency), + new LiveSocialSessionBindings( + Runtime.CommunicationOwner.Chat, + Runtime.CommunicationOwner.TurbineChat, + Runtime.CommunicationOwner.Friends, + Runtime.CommunicationOwner.Squelch)); + } + + private static LiveSessionCharacterSelector MapCharacterSelector( + HeadlessCharacterSelector selector) => + new( + selector.Index, + selector.Id, + selector.Name); + + private RuntimeSessionStartResult Convert( + LiveSessionStartResult result) + { + RuntimeSessionStartStatus status = result.Status switch + { + LiveSessionStartStatus.Disabled => + RuntimeSessionStartStatus.Disabled, + LiveSessionStartStatus.MissingCredentials => + RuntimeSessionStartStatus.MissingCredentials, + LiveSessionStartStatus.NoCharacters => + RuntimeSessionStartStatus.NoCharacters, + LiveSessionStartStatus.Connected => + RuntimeSessionStartStatus.Connected, + LiveSessionStartStatus.Deferred => + RuntimeSessionStartStatus.Deferred, + LiveSessionStartStatus.Failed => + RuntimeSessionStartStatus.Failed, + _ => throw new ArgumentOutOfRangeException( + nameof(result), + result.Status, + "Unknown live-session start result."), + }; + return new RuntimeSessionStartResult( + status, + Runtime.Generation, + result.Selection?.CharacterId ?? 0u, + result.Selection?.CharacterName ?? string.Empty, + result.Error); + } +} diff --git a/src/AcDream.Headless/Platform/HeadlessPathSet.cs b/src/AcDream.Headless/Platform/HeadlessPathSet.cs new file mode 100644 index 00000000..a7a841eb --- /dev/null +++ b/src/AcDream.Headless/Platform/HeadlessPathSet.cs @@ -0,0 +1,99 @@ +using AcDream.Headless.Configuration; + +namespace AcDream.Headless.Platform; + +internal sealed record HeadlessPathSet( + string ConfigDirectory, + string DataDirectory, + string CacheDirectory) +{ + internal static HeadlessPathSet Resolve( + HeadlessPathOverrides overrides, + IHeadlessPlatformEnvironment? platform = null) + { + ArgumentNullException.ThrowIfNull(overrides); + platform ??= HeadlessPlatformEnvironment.Instance; + + string config; + string data; + string cache; + if (platform.IsWindows) + { + string roaming = RequireFolder( + platform, + Environment.SpecialFolder.ApplicationData); + string local = RequireFolder( + platform, + Environment.SpecialFolder.LocalApplicationData); + config = Path.Combine(roaming, "acdream"); + data = Path.Combine(local, "acdream"); + cache = Path.Combine(local, "acdream", "cache"); + } + else + { + string home = RequireFolder( + platform, + Environment.SpecialFolder.UserProfile); + config = ResolveXdg( + platform, + "XDG_CONFIG_HOME", + Path.Combine(home, ".config"), + "acdream"); + data = ResolveXdg( + platform, + "XDG_DATA_HOME", + Path.Combine(home, ".local", "share"), + "acdream"); + cache = ResolveXdg( + platform, + "XDG_CACHE_HOME", + Path.Combine(home, ".cache"), + "acdream"); + } + + return new HeadlessPathSet( + Normalize(overrides.ConfigDirectory ?? config, platform), + Normalize(overrides.DataDirectory ?? data, platform), + Normalize(overrides.CacheDirectory ?? cache, platform)); + } + + private static string ResolveXdg( + IHeadlessPlatformEnvironment platform, + string variable, + string fallback, + string leaf) + { + string? configured = platform.GetEnvironmentVariable(variable); + string root = string.IsNullOrWhiteSpace(configured) + ? fallback + : configured; + return Path.Combine(root, leaf); + } + + private static string RequireFolder( + IHeadlessPlatformEnvironment platform, + Environment.SpecialFolder folder) + { + string value = platform.GetFolderPath(folder); + if (string.IsNullOrWhiteSpace(value)) + { + throw new HeadlessConfigurationException( + $"The operating system did not provide {folder}."); + } + return value; + } + + private static string Normalize( + string path, + IHeadlessPlatformEnvironment platform) + { + if (string.IsNullOrWhiteSpace(path)) + { + throw new HeadlessConfigurationException( + "Headless directories cannot be empty."); + } + + return Path.TrimEndingDirectorySeparator( + Path.GetFullPath(path, platform.CurrentDirectory)); + } +} diff --git a/src/AcDream.Headless/Platform/HeadlessPlatformEnvironment.cs b/src/AcDream.Headless/Platform/HeadlessPlatformEnvironment.cs new file mode 100644 index 00000000..976e25d1 --- /dev/null +++ b/src/AcDream.Headless/Platform/HeadlessPlatformEnvironment.cs @@ -0,0 +1,28 @@ +namespace AcDream.Headless.Platform; + +internal interface IHeadlessPlatformEnvironment +{ + bool IsWindows { get; } + string CurrentDirectory { get; } + string? GetEnvironmentVariable(string name); + string GetFolderPath(Environment.SpecialFolder folder); +} + +internal sealed class HeadlessPlatformEnvironment + : IHeadlessPlatformEnvironment +{ + internal static HeadlessPlatformEnvironment Instance { get; } = new(); + + private HeadlessPlatformEnvironment() + { + } + + public bool IsWindows => OperatingSystem.IsWindows(); + public string CurrentDirectory => Environment.CurrentDirectory; + + public string? GetEnvironmentVariable(string name) => + Environment.GetEnvironmentVariable(name); + + public string GetFolderPath(Environment.SpecialFolder folder) => + Environment.GetFolderPath(folder); +} diff --git a/src/AcDream.Headless/Policies/HeadlessBotPolicy.cs b/src/AcDream.Headless/Policies/HeadlessBotPolicy.cs new file mode 100644 index 00000000..41f02a99 --- /dev/null +++ b/src/AcDream.Headless/Policies/HeadlessBotPolicy.cs @@ -0,0 +1,181 @@ +using AcDream.Headless.Configuration; +using AcDream.Runtime; + +namespace AcDream.Headless.Policies; + +internal interface IHeadlessBotPolicy : IRuntimeEventObserver, IDisposable +{ + bool IsComplete { get; } + + void Tick(IGameRuntimeView view, IGameRuntimeCommands commands); +} + +internal static class HeadlessBotPolicyFactory +{ + internal static IHeadlessBotPolicy Create(string id) => + id switch + { + "idle" => new IdleHeadlessBotPolicy(), + "lifecycle-smoke" => new LifecycleSmokeHeadlessBotPolicy(), + _ => throw new HeadlessConfigurationException( + $"Unknown headless bot policy '{id}'."), + }; +} + +internal sealed class IdleHeadlessBotPolicy : IHeadlessBotPolicy +{ + public bool IsComplete => false; + + public void Tick( + IGameRuntimeView view, + IGameRuntimeCommands commands) + { + ArgumentNullException.ThrowIfNull(view); + ArgumentNullException.ThrowIfNull(commands); + } + + public void OnLifecycle(in RuntimeLifecycleDelta delta) + { + } + + public void OnCommand(in RuntimeCommandDelta delta) + { + } + + public void OnEntity(in RuntimeEntityDelta delta) + { + } + + public void OnInventory(in RuntimeInventoryDelta delta) + { + } + + public void OnChat(in RuntimeChatDelta delta) + { + } + + public void OnMovement(in RuntimeMovementDelta delta) + { + } + + public void OnPortal(in RuntimePortalDelta delta) + { + } + + public void Dispose() + { + } +} + +/// +/// Explicit connected-gate policy: wait for the local player, issue one +/// harmless local-speech command and one lifestone recall, reconnect after +/// the exact portal completes, then terminate after the new generation has +/// materialized the player. It is never selected implicitly. +/// +internal sealed class LifecycleSmokeHeadlessBotPolicy + : IHeadlessBotPolicy +{ + private int _stage; + private ulong _firstGeneration; + + public bool IsComplete => _stage == 3; + + public void Tick( + IGameRuntimeView view, + IGameRuntimeCommands commands) + { + ArgumentNullException.ThrowIfNull(view); + ArgumentNullException.ThrowIfNull(commands); + switch (_stage) + { + case 0: + if (!HasLocalPlayer(view)) + return; + Require(commands.Chat.Execute( + view.Generation, + new RuntimeChatCommand( + RuntimeChatChannel.Say, + "[acdream headless lifecycle gate]"))); + Require(commands.Portal.Execute( + view.Generation, + RuntimePortalCommand.RecallLifestone)); + _firstGeneration = view.Generation.Value; + _stage = 1; + break; + case 1: + if (view.Portal.Snapshot.Kind + is not RuntimePortalKind.Portal + || !view.Portal.Snapshot.Completed) + { + return; + } + RuntimeSessionStartResult reconnect = + commands.Session.Reconnect(view.Generation); + if (reconnect.Status + != RuntimeSessionStartStatus.Connected) + { + throw new InvalidOperationException( + $"Lifecycle gate reconnect was rejected with {reconnect.Status}."); + } + _stage = 2; + break; + case 2: + if (view.Generation.Value <= _firstGeneration + || !HasLocalPlayer(view)) + { + return; + } + _stage = 3; + break; + } + } + + public void OnLifecycle(in RuntimeLifecycleDelta delta) + { + } + + public void OnCommand(in RuntimeCommandDelta delta) + { + } + + public void OnEntity(in RuntimeEntityDelta delta) + { + } + + public void OnInventory(in RuntimeInventoryDelta delta) + { + } + + public void OnChat(in RuntimeChatDelta delta) + { + } + + public void OnMovement(in RuntimeMovementDelta delta) + { + } + + public void OnPortal(in RuntimePortalDelta delta) + { + } + + public void Dispose() + { + } + + private static bool HasLocalPlayer(IGameRuntimeView view) => + view.Lifecycle.State is RuntimeLifecycleState.InWorld + && view.Lifecycle.PlayerGuid != 0u + && view.Entities.TryGet( + view.Lifecycle.PlayerGuid, + out _); + + private static void Require(in RuntimeCommandResult result) + { + if (!result.Accepted) + { + throw new InvalidOperationException( + $"Lifecycle gate command was rejected with {result.Status}."); + } + } +} diff --git a/src/AcDream.Headless/Program.cs b/src/AcDream.Headless/Program.cs index 2ceddadf..4fae4031 100644 --- a/src/AcDream.Headless/Program.cs +++ b/src/AcDream.Headless/Program.cs @@ -1,3 +1,36 @@ +using System.Runtime.InteropServices; using AcDream.Headless; -return HeadlessEntryPoint.Run(args, Console.Out, Console.Error); +using var cancellation = new CancellationTokenSource(); +ConsoleCancelEventHandler cancel = (_, eventArgs) => +{ + eventArgs.Cancel = true; + cancellation.Cancel(); +}; +Console.CancelKeyPress += cancel; +PosixSignalRegistration? terminate = null; +if (!OperatingSystem.IsWindows()) +{ + terminate = PosixSignalRegistration.Create( + PosixSignal.SIGTERM, + context => + { + context.Cancel = true; + cancellation.Cancel(); + }); +} + +try +{ + return HeadlessEntryPoint.Run( + args, + Console.In, + Console.Out, + Console.Error, + cancellation.Token); +} +finally +{ + terminate?.Dispose(); + Console.CancelKeyPress -= cancel; +} diff --git a/src/AcDream.Runtime/Session/DirectGameRuntimeCommandAdapter.cs b/src/AcDream.Runtime/Session/DirectGameRuntimeCommandAdapter.cs new file mode 100644 index 00000000..0827fe75 --- /dev/null +++ b/src/AcDream.Runtime/Session/DirectGameRuntimeCommandAdapter.cs @@ -0,0 +1,427 @@ +using AcDream.Core.Net; +using AcDream.Runtime.Gameplay; + +namespace AcDream.Runtime.Session; + +/// +/// Typed command surface for a presentation-free Runtime host. K1 supplies +/// session, local chat, and recall operations; later Slice-K work extends the +/// same adapter rather than introducing a second bot command model. +/// +public sealed class DirectGameRuntimeCommandAdapter + : IGameRuntimeCommands, + IRuntimeSessionCommands, + IRuntimeSelectionCommands, + IRuntimeCombatCommands, + IRuntimeMagicCommands, + IRuntimeMovementCommands, + IRuntimeChatCommands, + IRuntimePortalCommands, + IRuntimeInventoryStateCommands, + IRuntimeSpellbookCommands, + IRuntimeCharacterCommands, + IRuntimeSocialCommands +{ + private sealed class CommandRoute( + DirectGameRuntimeCommandAdapter owner, + WorldSession session, + RuntimeGenerationToken generation) + : ILiveSessionCommandRouting + { + private bool _active; + + public void Activate() + { + if (_active) + return; + owner.Activate(session, generation, this); + _active = true; + } + + public void Dispose() + { + owner.Deactivate(this); + _active = false; + } + } + + private readonly object _gate = new(); + private readonly GameRuntime _runtime; + private readonly IRuntimeSessionCommands _sessionCommands; + private WorldSession? _session; + private CommandRoute? _route; + private RuntimeGenerationToken _routeGeneration; + + public DirectGameRuntimeCommandAdapter( + GameRuntime runtime, + IRuntimeSessionCommands sessionCommands) + { + _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); + _sessionCommands = sessionCommands + ?? throw new ArgumentNullException(nameof(sessionCommands)); + } + + public IRuntimeSessionCommands Session => this; + public IRuntimeSelectionCommands Selection => this; + public IRuntimeCombatCommands Combat => this; + public IRuntimeMagicCommands Magic => this; + public IRuntimeMovementCommands Movement => this; + public IRuntimeChatCommands Chat => this; + public IRuntimePortalCommands Portal => this; + public IRuntimeInventoryStateCommands InventoryState => this; + public IRuntimeSpellbookCommands Spellbook => this; + public IRuntimeCharacterCommands Character => this; + public IRuntimeSocialCommands Social => this; + + public ILiveSessionCommandRouting CreateRoute(WorldSession session) + { + ArgumentNullException.ThrowIfNull(session); + return new CommandRoute(this, session, _runtime.Generation); + } + + public RuntimeSessionStartResult Start( + RuntimeGenerationToken expectedGeneration) + { + RuntimeLifecycleState previous = _runtime.Lifecycle.State; + RuntimeSessionStartResult result = + _sessionCommands.Start(expectedGeneration); + _runtime.EventSink.EmitLifecycle( + previous, + _runtime.Lifecycle.State); + return result; + } + + public RuntimeSessionStartResult Reconnect( + RuntimeGenerationToken expectedGeneration) + { + RuntimeLifecycleState previous = _runtime.Lifecycle.State; + RuntimeSessionStartResult result = + _sessionCommands.Reconnect(expectedGeneration); + _runtime.EventSink.EmitLifecycle( + previous, + _runtime.Lifecycle.State); + return result; + } + + public RuntimeTeardownAcknowledgement Stop( + RuntimeGenerationToken expectedGeneration) + { + RuntimeLifecycleState previous = _runtime.Lifecycle.State; + RuntimeTeardownAcknowledgement result = + _sessionCommands.Stop(expectedGeneration); + _runtime.EventSink.EmitLifecycle( + previous, + _runtime.Lifecycle.State); + return result; + } + + public RuntimeCommandResult Execute( + RuntimeGenerationToken expectedGeneration, + in RuntimeChatCommand command) + { + RuntimeCommandStatus gate = + Validate(expectedGeneration, out WorldSession? session); + if (gate != RuntimeCommandStatus.Accepted) + return Result(gate); + if (string.IsNullOrWhiteSpace(command.Text)) + return EmitUnsupported( + RuntimeCommandDomain.Chat, + (int)command.Channel, + RuntimeCommandStatus.Rejected); + + switch (command.Channel) + { + case RuntimeChatChannel.Say: + session!.SendTalk(command.Text); + break; + case RuntimeChatChannel.Tell + when !string.IsNullOrWhiteSpace(command.TargetName): + session!.SendTell(command.TargetName, command.Text); + break; + default: + return EmitUnsupported( + RuntimeCommandDomain.Chat, + (int)command.Channel); + } + + _runtime.EventSink.EmitCommand( + RuntimeCommandDomain.Chat, + (int)command.Channel, + RuntimeCommandStatus.Accepted, + text: command.Text); + return Result(RuntimeCommandStatus.Accepted); + } + + public RuntimeCommandResult Execute( + RuntimeGenerationToken expectedGeneration, + RuntimePortalCommand command) + { + RuntimeCommandStatus gate = + Validate(expectedGeneration, out WorldSession? session); + if (gate != RuntimeCommandStatus.Accepted) + return Result(gate); + + switch (command) + { + case RuntimePortalCommand.RecallLifestone: + session!.SendTeleportToLifestone(); + break; + case RuntimePortalCommand.RecallMarketplace: + session!.SendTeleportToMarketplace(); + break; + case RuntimePortalCommand.RecallHouse: + session!.SendTeleportToHouse(); + break; + case RuntimePortalCommand.RecallMansion: + session!.SendTeleportToMansion(); + break; + default: + return EmitUnsupported( + RuntimeCommandDomain.Portal, + (int)command); + } + + _runtime.EventSink.EmitCommand( + RuntimeCommandDomain.Portal, + (int)command, + RuntimeCommandStatus.Accepted); + return Result(RuntimeCommandStatus.Accepted); + } + + public RuntimeCommandResult Execute( + RuntimeGenerationToken expectedGeneration, + RuntimeSelectionCommand command) => + Unsupported( + expectedGeneration, + RuntimeCommandDomain.Selection, + (int)command); + + public RuntimeCommandResult Execute( + RuntimeGenerationToken expectedGeneration, + RuntimeCombatCommand command) => + Unsupported( + expectedGeneration, + RuntimeCommandDomain.Combat, + (int)command); + + public RuntimeCommandResult ExecuteAttack( + RuntimeGenerationToken expectedGeneration, + in RuntimeCombatAttackInput command) => + Unsupported( + expectedGeneration, + RuntimeCommandDomain.Combat, + (int)command.Command); + + public RuntimeCommandResult Execute( + RuntimeGenerationToken expectedGeneration, + in RuntimeMagicCommand command) => + Unsupported( + expectedGeneration, + RuntimeCommandDomain.Magic, + operation: 0, + command.SpellId); + + public RuntimeCommandResult Execute( + RuntimeGenerationToken expectedGeneration, + RuntimeMovementCommand command) => + Unsupported( + expectedGeneration, + RuntimeCommandDomain.Movement, + (int)command); + + public RuntimeCommandResult AddShortcut( + RuntimeGenerationToken expectedGeneration, + in RuntimeShortcutCommand command) => + Unsupported( + expectedGeneration, + RuntimeCommandDomain.InventoryState, + operation: 0, + command.ObjectId); + + public RuntimeCommandResult RemoveShortcut( + RuntimeGenerationToken expectedGeneration, + int index) => + Unsupported( + expectedGeneration, + RuntimeCommandDomain.InventoryState, + operation: 1); + + public RuntimeCommandResult AddFavorite( + RuntimeGenerationToken expectedGeneration, + int tabIndex, + int position, + uint spellId) => + Unsupported( + expectedGeneration, + RuntimeCommandDomain.Spellbook, + operation: 0, + spellId); + + public RuntimeCommandResult RemoveFavorite( + RuntimeGenerationToken expectedGeneration, + int tabIndex, + uint spellId) => + Unsupported( + expectedGeneration, + RuntimeCommandDomain.Spellbook, + operation: 1, + spellId); + + public RuntimeCommandResult SetFilter( + RuntimeGenerationToken expectedGeneration, + uint filters) => + Unsupported( + expectedGeneration, + RuntimeCommandDomain.Spellbook, + operation: 2); + + public RuntimeCommandResult ForgetSpell( + RuntimeGenerationToken expectedGeneration, + uint spellId) => + Unsupported( + expectedGeneration, + RuntimeCommandDomain.Spellbook, + operation: 3, + spellId); + + public RuntimeCommandResult SetDesiredComponent( + RuntimeGenerationToken expectedGeneration, + uint componentId, + uint amount) => + Unsupported( + expectedGeneration, + RuntimeCommandDomain.Spellbook, + operation: 4, + componentId); + + public RuntimeCommandResult ClearDesiredComponents( + RuntimeGenerationToken expectedGeneration) => + Unsupported( + expectedGeneration, + RuntimeCommandDomain.Spellbook, + operation: 5); + + public RuntimeCommandResult Advance( + RuntimeGenerationToken expectedGeneration, + in RuntimeAdvancementCommand command) => + Unsupported( + expectedGeneration, + RuntimeCommandDomain.Character, + (int)command.Kind, + command.StatId); + + public RuntimeCommandResult SetOptions1( + RuntimeGenerationToken expectedGeneration, + uint options) => + Unsupported( + expectedGeneration, + RuntimeCommandDomain.Character, + operation: 4); + + public RuntimeCommandResult Execute( + RuntimeGenerationToken expectedGeneration, + in RuntimeFriendCommand command) => + Unsupported( + expectedGeneration, + RuntimeCommandDomain.Social, + (int)command.Kind, + command.CharacterId); + + public RuntimeCommandResult Execute( + RuntimeGenerationToken expectedGeneration, + in RuntimeSquelchCommand command) => + Unsupported( + expectedGeneration, + RuntimeCommandDomain.Social, + (int)command.Scope, + command.CharacterId); + + private RuntimeCommandResult Unsupported( + RuntimeGenerationToken expectedGeneration, + RuntimeCommandDomain domain, + int operation, + uint primaryObjectId = 0u) + { + RuntimeCommandStatus gate = + Validate(expectedGeneration, out _); + return gate == RuntimeCommandStatus.Accepted + ? EmitUnsupported( + domain, + operation, + RuntimeCommandStatus.Unsupported, + primaryObjectId) + : Result(gate); + } + + private RuntimeCommandResult EmitUnsupported( + RuntimeCommandDomain domain, + int operation, + RuntimeCommandStatus status = RuntimeCommandStatus.Unsupported, + uint primaryObjectId = 0u) + { + _runtime.EventSink.EmitCommand( + domain, + operation, + status, + primaryObjectId); + return Result(status, primaryObjectId); + } + + private RuntimeCommandStatus Validate( + RuntimeGenerationToken expectedGeneration, + out WorldSession? session) + { + lock (_gate) + { + if (expectedGeneration != _runtime.Generation + || (_route is not null + && _routeGeneration != expectedGeneration)) + { + session = null; + return RuntimeCommandStatus.StaleGeneration; + } + + session = _session; + return _route is not null + && session is not null + && _runtime.Session.IsInWorld + ? RuntimeCommandStatus.Accepted + : RuntimeCommandStatus.Inactive; + } + } + + private RuntimeCommandResult Result( + RuntimeCommandStatus status, + uint objectId = 0u) => + new(status, _runtime.Generation, objectId); + + private void Activate( + WorldSession session, + RuntimeGenerationToken generation, + CommandRoute route) + { + lock (_gate) + { + if (_route is not null) + { + throw new InvalidOperationException( + "A direct Runtime command route is already active."); + } + _session = session; + _routeGeneration = generation; + _route = route; + } + } + + private void Deactivate(CommandRoute route) + { + lock (_gate) + { + if (!ReferenceEquals(_route, route)) + return; + _route = null; + _session = null; + _routeGeneration = default; + } + } +} diff --git a/src/AcDream.Runtime/Session/LiveSessionContracts.cs b/src/AcDream.Runtime/Session/LiveSessionContracts.cs index 1a992acb..49ca27d2 100644 --- a/src/AcDream.Runtime/Session/LiveSessionContracts.cs +++ b/src/AcDream.Runtime/Session/LiveSessionContracts.cs @@ -2,12 +2,18 @@ using AcDream.Core.Net; namespace AcDream.Runtime.Session; +public sealed record LiveSessionCharacterSelector( + int? ActiveIndex = null, + uint? CharacterId = null, + string? CharacterName = null); + public sealed record LiveSessionConnectOptions( bool Enabled, string Host, int Port, string User, - string Password); + string Password, + LiveSessionCharacterSelector? Character = null); public interface IRuntimeLiveSessionFramePhase { diff --git a/src/AcDream.Runtime/Session/LiveSessionController.cs b/src/AcDream.Runtime/Session/LiveSessionController.cs index 04efe483..ee352c97 100644 --- a/src/AcDream.Runtime/Session/LiveSessionController.cs +++ b/src/AcDream.Runtime/Session/LiveSessionController.cs @@ -556,7 +556,10 @@ public sealed class LiveSessionController CharacterList.Parsed? characters = _operations.GetCharacters(session); if (characters is null - || !CharacterList.TrySelectFirstAvailable(characters, out CharacterList.Selection selected)) + || !TrySelectCharacter( + characters, + options.Character, + out CharacterList.Selection selected)) { Console.WriteLine("live: no available characters on account; disconnecting"); StopCore(); @@ -755,6 +758,62 @@ public sealed class LiveSessionController private LiveSessionStartResult ConnectedResult() => new(LiveSessionStartStatus.Connected, _activeSelection); + private static bool TrySelectCharacter( + CharacterList.Parsed characters, + LiveSessionCharacterSelector? selector, + out CharacterList.Selection selection) + { + ArgumentNullException.ThrowIfNull(characters); + if (selector is null) + { + return CharacterList.TrySelectFirstAvailable( + characters, + out selection); + } + + int selectorCount = + (selector.ActiveIndex.HasValue ? 1 : 0) + + (selector.CharacterId.HasValue ? 1 : 0) + + (!string.IsNullOrWhiteSpace(selector.CharacterName) ? 1 : 0); + if (selectorCount != 1) + { + selection = default; + return false; + } + + for (int index = 0; index < characters.Characters.Count; index++) + { + CharacterList.Character character = + characters.Characters[index]; + if (!CharacterList.IsAvailableActiveIdentity(character)) + continue; + if (selector.ActiveIndex is { } requestedIndex + && requestedIndex != index) + { + continue; + } + if (selector.CharacterId is { } requestedId + && requestedId != character.Id) + { + continue; + } + if (selector.CharacterName is { } requestedName + && !string.Equals( + requestedName, + character.Name, + StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + selection = new CharacterList.Selection(index, character); + return true; + } + + selection = default; + return false; + } + private void ThrowIfDisposing() { if (_disposeRequested || _disposed) diff --git a/src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs b/src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs new file mode 100644 index 00000000..17ab4117 --- /dev/null +++ b/src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs @@ -0,0 +1,291 @@ +using System.Numerics; +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; +using AcDream.Core.Physics; +using AcDream.Runtime.Entities; +using AcDream.Runtime.World; + +namespace AcDream.Runtime.Session; + +/// +/// Presentation-free inbound entity route for a direct Runtime host. It +/// applies the same canonical identity, timestamp, object-table, and transit +/// owners used by the graphical route without constructing App hydration, +/// rendering, animation, or effect projections. +/// +public sealed class RuntimeLiveEntitySessionController +{ + private readonly GameRuntime _runtime; + private readonly WorldSession _session; + private readonly Action _log; + + public RuntimeLiveEntitySessionController( + GameRuntime runtime, + WorldSession session, + Action? log = null) + { + _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); + _session = session ?? throw new ArgumentNullException(nameof(session)); + _log = log ?? (_ => { }); + } + + public LiveEntitySessionSink CreateSink() => new( + OnSpawned, + OnDeleted, + OnPickedUp, + OnMotionUpdated, + OnPositionUpdated, + OnVectorUpdated, + OnStateUpdated, + OnParentUpdated, + OnTeleportStarted, + OnAppearanceUpdated, + _ => { }, + _ => { }); + + private RuntimeEntityObjectLifetime Entities => + _runtime.EntityObjects; + + private void OnSpawned(WorldSession.EntitySpawn spawn) + { + RuntimeEntityRegistrationResult registration = + Entities.RegisterEntity(spawn); + if (registration.Canonical is not { } canonical) + return; + + ulong integrationVersion = canonical.CreateIntegrationVersion; + _ = Entities.ApplyAcceptedSpawn( + canonical, + integrationVersion, + canonical.Snapshot, + replaceGeneration: + registration.Inbound.Disposition + is CreateObjectTimestampDisposition.NewGeneration); + } + + private void OnDeleted(DeleteObject.Parsed delete) + { + if (delete.Guid == _runtime.PlayerIdentity.ServerGuid + || !Entities.TryAcceptDelete( + delete, + isLocalPlayer: false, + removeRetainedObject: true, + out RuntimeEntityDeleteAcceptance acceptance)) + { + return; + } + + Entities.CompleteAcceptedDelete(acceptance); + if (acceptance.RetiredCanonical is { } retired) + { + Exception? failure = Entities.RetireCanonicalOnly(retired); + if (failure is not null) + throw failure; + } + } + + private void OnPickedUp(PickupEvent.Parsed pickup) => + _ = Entities.TryApplyPickup( + pickup, + acknowledgeProjection: null, + out _); + + private void OnMotionUpdated( + WorldSession.EntityMotionUpdate update) + { + bool isLocal = + update.Guid == _runtime.PlayerIdentity.ServerGuid; + _ = Entities.TryApplyMotion( + update, + retainPayload: !isLocal || !update.IsAutonomous, + acknowledgeProjection: null, + out _, + out _); + } + + private void OnPositionUpdated( + WorldSession.EntityPositionUpdate update) + { + bool isLocal = + update.Guid == _runtime.PlayerIdentity.ServerGuid; + bool known = Entities.TryApplyPosition( + update, + isLocal, + forcePositionRotation: null, + currentLocalVelocity: null, + projectionRequiresTeleportHook: false, + acknowledgeProjection: null, + out PositionTimestampDisposition disposition, + out _, + out AcceptedPhysicsTimestamps timestamps); + if (!known + || !isLocal + || disposition is PositionTimestampDisposition.Rejected) + { + return; + } + + var position = update.Position; + var destination = new RuntimeTeleportDestination( + update.Guid, + update.InstanceSequence, + update.PositionSequence, + update.TeleportSequence, + update.ForcePositionSequence, + new Position( + position.LandblockId, + new Vector3( + position.PositionX, + position.PositionY, + position.PositionZ), + new Quaternion( + position.RotationX, + position.RotationY, + position.RotationZ, + position.RotationW))); + _runtime.TransitOwner.OfferTeleportDestination( + destination, + timestamps.TeleportAdvanced); + TryCompletePortal(); + } + + private void OnVectorUpdated(VectorUpdate.Parsed update) => + _ = Entities.TryApplyVector( + update, + acknowledgeProjection: null, + out _); + + private void OnStateUpdated(SetState.Parsed update) => + _ = Entities.TryApplyState( + update, + acknowledgeProjection: null, + out _, + out _); + + private void OnParentUpdated(ParentEvent.Parsed update) => + _ = Entities.TryApplyParent( + update, + acknowledgeProjection: null, + out _); + + private void OnTeleportStarted(uint rawSequence) + { + ushort sequence = unchecked((ushort)rawSequence); + RuntimeWorldTransitState transit = _runtime.TransitOwner; + if (!transit.TryQueueTeleportStart(sequence)) + return; + if (!transit.ActivateQueuedTeleport()) + { + throw new InvalidOperationException( + "Runtime rejected its queued headless teleport activation."); + } + TryCompletePortal(); + } + + private void OnAppearanceUpdated(ObjDescEvent.Parsed update) => + _ = Entities.TryApplyObjDesc( + update, + acknowledgeProjection: null, + out _); + + private void TryCompletePortal() + { + RuntimeWorldTransitState transit = _runtime.TransitOwner; + if (!transit.TryGetAcceptedTeleportDestination( + out RuntimeTeleportDestination destination) + || !transit.TryBeginPortalReveal( + destination.TeleportSequence, + destination.CellId, + out long generation)) + { + return; + } + + if (!transit.TryRegisterHostProjection( + generation, + destination.CellId, + out RuntimeWorldHostProjectionToken projection)) + { + throw new InvalidOperationException( + "Runtime rejected the headless portal projection."); + } + + Acknowledge( + transit, + projection, + RuntimeWorldHostAcknowledgementStage.ProjectionRegistered); + + bool indoor = (destination.CellId & 0xFFFFu) >= 0x0100u; + if (!transit.AcknowledgeDestinationReadiness( + new RuntimeDestinationReadiness( + generation, + destination.CellId, + indoor, + IsUnhydratable: false, + RequiredRenderRadius: indoor ? 0 : 1, + IsRenderNeighborhoodReady: true, + AreCompositeTexturesReady: true, + IsCollisionReady: true))) + { + throw new InvalidOperationException( + "Runtime rejected headless destination readiness."); + } + + if (!transit.AcknowledgePortalMaterialized( + generation, + destination.TeleportSequence, + destination.CellId)) + { + throw new InvalidOperationException( + "Runtime rejected headless portal materialization."); + } + Acknowledge( + transit, + projection, + RuntimeWorldHostAcknowledgementStage + .SimulationReleaseProjected); + + if (!transit.RequireDestinationReservationRelease(projection)) + { + throw new InvalidOperationException( + "Runtime rejected headless destination release."); + } + Acknowledge( + transit, + projection, + RuntimeWorldHostAcknowledgementStage + .DestinationReservationReleased); + + if (!transit.AcknowledgeWorldViewportVisible(generation) + || !transit.Complete(generation)) + { + throw new InvalidOperationException( + "Runtime rejected headless portal completion."); + } + Acknowledge( + transit, + projection, + RuntimeWorldHostAcknowledgementStage.TerminalProjected); + + _session.SendGameAction(GameActionLoginComplete.Build()); + transit.EndTeleport(); + _log( + $"headless: portal complete generation={generation} " + + $"cell=0x{destination.CellId:X8}"); + } + + private static void Acknowledge( + RuntimeWorldTransitState transit, + RuntimeWorldHostProjectionToken projection, + RuntimeWorldHostAcknowledgementStage stage) + { + if (!transit.AcknowledgeHostProjection( + new RuntimeWorldHostAcknowledgement( + projection, + stage))) + { + throw new InvalidOperationException( + $"Runtime rejected headless host acknowledgement {stage}."); + } + } +} diff --git a/tests/AcDream.Headless.Tests/HeadlessCredentialResolverTests.cs b/tests/AcDream.Headless.Tests/HeadlessCredentialResolverTests.cs new file mode 100644 index 00000000..f21a79fb --- /dev/null +++ b/tests/AcDream.Headless.Tests/HeadlessCredentialResolverTests.cs @@ -0,0 +1,173 @@ +using AcDream.Headless.Configuration; +using AcDream.Headless.Credentials; + +namespace AcDream.Headless.Tests; + +public sealed class HeadlessCredentialResolverTests +{ + [Fact] + public void EnvironmentSecretIsRedactedAndErasable() + { + const string secretValue = "test-secret-value"; + var environment = new FixtureCredentialEnvironment( + isLinux: false, + new Dictionary + { + ["BOT_PASSWORD"] = secretValue, + }); + var resolver = new HeadlessCredentialResolver( + TextReader.Null, + Environment.CurrentDirectory, + environment); + + HeadlessCredentialSecret secret = resolver.Resolve( + "bot", + new HeadlessCredentialReference + { + Provider = HeadlessCredentialProviderKind.Environment, + Reference = "BOT_PASSWORD", + }); + + Assert.Equal(secretValue, secret.Reveal()); + Assert.DoesNotContain(secretValue, secret.ToString()); + secret.Dispose(); + Assert.True(secret.IsDisposed); + Assert.Throws(secret.Reveal); + } + + [Fact] + public void StandardInputConsumesOneSecretWithoutEchoingIt() + { + const string secretValue = "stdin-secret"; + var resolver = new HeadlessCredentialResolver( + new StringReader(secretValue + Environment.NewLine), + Environment.CurrentDirectory, + new FixtureCredentialEnvironment( + false, + new Dictionary())); + + using HeadlessCredentialSecret secret = resolver.Resolve( + "bot", + new HeadlessCredentialReference + { + Provider = HeadlessCredentialProviderKind.StandardInput, + Reference = "bot-stdin", + }); + + Assert.Equal(secretValue, secret.Reveal()); + Assert.DoesNotContain(secretValue, secret.ToString()); + } + + [Fact] + public void CredentialFileIsResolvedRelativeToConfiguredDirectory() + { + string directory = Path.Combine( + Path.GetTempPath(), + $"acdream-credentials-{Guid.NewGuid():N}"); + Directory.CreateDirectory(directory); + string path = Path.Combine(directory, "bot.pass"); + File.WriteAllText(path, "file-secret" + Environment.NewLine); + try + { + var resolver = new HeadlessCredentialResolver( + TextReader.Null, + directory, + new FixtureCredentialEnvironment( + false, + new Dictionary())); + + using HeadlessCredentialSecret secret = resolver.Resolve( + "bot", + new HeadlessCredentialReference + { + Provider = HeadlessCredentialProviderKind.File, + Reference = "bot.pass", + }); + + Assert.Equal("file-secret", secret.Reveal()); + } + finally + { + File.Delete(path); + Directory.Delete(directory); + } + } + + [Fact] + public void MissingSecretErrorNeverContainsAnotherSecret() + { + const string unrelatedSecret = "must-not-leak"; + var resolver = new HeadlessCredentialResolver( + new StringReader(string.Empty), + Environment.CurrentDirectory, + new FixtureCredentialEnvironment( + false, + new Dictionary + { + ["OTHER"] = unrelatedSecret, + })); + + HeadlessCredentialException error = + Assert.Throws(() => + resolver.Resolve( + "bot", + new HeadlessCredentialReference + { + Provider = + HeadlessCredentialProviderKind.Environment, + Reference = "MISSING", + })); + + Assert.DoesNotContain(unrelatedSecret, error.ToString()); + } + + [Fact] + public void LinuxRejectsGroupOrOtherCredentialPermissions() + { + if (!OperatingSystem.IsLinux()) + return; + + string path = Path.Combine( + Path.GetTempPath(), + $"acdream-credential-{Guid.NewGuid():N}"); + File.WriteAllText(path, "linux-secret"); + File.SetUnixFileMode( + path, + UnixFileMode.UserRead | UnixFileMode.GroupRead); + try + { + var resolver = new HeadlessCredentialResolver( + TextReader.Null, + Path.GetDirectoryName(path)!, + new FixtureCredentialEnvironment( + true, + new Dictionary())); + + Assert.Throws(() => + resolver.Resolve( + "bot", + new HeadlessCredentialReference + { + Provider = HeadlessCredentialProviderKind.File, + Reference = Path.GetFileName(path), + })); + } + finally + { + File.Delete(path); + } + } + + private sealed class FixtureCredentialEnvironment( + bool isLinux, + IReadOnlyDictionary values) + : IHeadlessCredentialEnvironment + { + public bool IsLinux { get; } = isLinux; + + public string? GetEnvironmentVariable(string name) => + values.TryGetValue(name, out string? value) + ? value + : null; + } +} diff --git a/tests/AcDream.Headless.Tests/HeadlessEntryPointTests.cs b/tests/AcDream.Headless.Tests/HeadlessEntryPointTests.cs index 010da026..10ada977 100644 --- a/tests/AcDream.Headless.Tests/HeadlessEntryPointTests.cs +++ b/tests/AcDream.Headless.Tests/HeadlessEntryPointTests.cs @@ -41,10 +41,9 @@ public sealed class HeadlessEntryPointTests [Theory] [InlineData("""{"version":2,"sessions":[]}""", "Unsupported configuration version")] - [InlineData("""{"version":1,"sessions":[],"password":"secret"}""", "could not be mapped")] - [InlineData("""{"version":1,"sessions":[{"id":"bot"},{"id":"bot"}]}""", "Duplicate session id")] + [InlineData("""{"version":1,"sessions":[],"password":"secret"}""", "JSON document is invalid")] [InlineData("""{"version":1,"sessions":[null]}""", "non-empty id")] - [InlineData("""{"version":1}""", "required properties")] + [InlineData("""{"version":1}""", "JSON document is invalid")] public void ValidateRejectsInvalidOrSecretShapedConfiguration( string json, string expectedError) @@ -67,6 +66,51 @@ public sealed class HeadlessEntryPointTests Assert.Equal(string.Empty, output.ToString()); } + [Fact] + public void ValidateAcceptsCompleteSingleSessionConfiguration() + { + using var file = TemporaryConfiguration.Create( + ConfigurationWith(Session("bot", "BOT_PASSWORD"))); + using var output = new StringWriter(); + using var error = new StringWriter(); + + int exitCode = HeadlessEntryPoint.Run( + ["validate", "--config", file.Path], + output, + error); + + Assert.Equal(0, exitCode); + Assert.Contains("1 session(s)", output.ToString()); + Assert.Equal(string.Empty, error.ToString()); + } + + [Theory] + [InlineData("duplicate-id")] + [InlineData("duplicate-credential")] + public void ValidateRejectsAmbiguousSessionOwnership(string scenario) + { + string second = scenario == "duplicate-id" + ? Session("bot", "SECOND_PASSWORD") + : Session("second", "BOT_PASSWORD"); + string expected = scenario == "duplicate-id" + ? "Duplicate session id" + : "already in use"; + using var file = TemporaryConfiguration.Create( + ConfigurationWith( + Session("bot", "BOT_PASSWORD"), + second)); + using var output = new StringWriter(); + using var error = new StringWriter(); + + int exitCode = HeadlessEntryPoint.Run( + ["validate", "--config", file.Path], + output, + error); + + Assert.Equal(3, exitCode); + Assert.Contains(expected, error.ToString()); + } + [Fact] public void UnknownCommandReturnsUsageError() { @@ -83,6 +127,15 @@ public sealed class HeadlessEntryPointTests Assert.Equal(string.Empty, output.ToString()); } + private static string ConfigurationWith(params string[] sessions) => + $$"""{"version":1,"sessions":[{{string.Join(",", sessions)}}]}"""; + + private static string Session(string id, string credentialReference) => + $"{{\"id\":\"{id}\",\"endpoint\":{{\"host\":\"127.0.0.1\",\"port\":9000}}," + + "\"account\":\"account\",\"character\":{\"index\":0}," + + "\"policy\":{\"id\":\"idle\"},\"credential\":" + + $"{{\"provider\":\"environment\",\"reference\":\"{credentialReference}\"}}}}"; + private sealed class TemporaryConfiguration : IDisposable { private TemporaryConfiguration(string path) diff --git a/tests/AcDream.Headless.Tests/HeadlessPathSetTests.cs b/tests/AcDream.Headless.Tests/HeadlessPathSetTests.cs new file mode 100644 index 00000000..6d562193 --- /dev/null +++ b/tests/AcDream.Headless.Tests/HeadlessPathSetTests.cs @@ -0,0 +1,129 @@ +using AcDream.Headless.Configuration; +using AcDream.Headless.Platform; + +namespace AcDream.Headless.Tests; + +public sealed class HeadlessPathSetTests +{ + [Fact] + public void LinuxUsesXdgDirectoriesWhenConfigured() + { + string root = Path.GetFullPath( + Path.Combine(Path.GetTempPath(), "acdream-xdg")); + var platform = new FixturePlatform(isWindows: false) + { + CurrentDirectoryValue = Path.Combine(root, "work"), + UserProfile = Path.Combine(root, "home", "bot"), + Variables = + { + ["XDG_CONFIG_HOME"] = Path.Combine(root, "cfg"), + ["XDG_DATA_HOME"] = Path.Combine(root, "data"), + ["XDG_CACHE_HOME"] = Path.Combine(root, "cache"), + }, + }; + + HeadlessPathSet paths = HeadlessPathSet.Resolve( + new HeadlessPathOverrides(), + platform); + + Assert.Equal( + Path.Combine(root, "cfg", "acdream"), + paths.ConfigDirectory); + Assert.Equal( + Path.Combine(root, "data", "acdream"), + paths.DataDirectory); + Assert.Equal( + Path.Combine(root, "cache", "acdream"), + paths.CacheDirectory); + } + + [Fact] + public void LinuxFallsBackToHomeAndNormalizesOverrides() + { + var platform = new FixturePlatform(isWindows: false) + { + CurrentDirectoryValue = Path.GetFullPath( + Path.Combine(Path.GetTempPath(), "acdream work")), + UserProfile = Path.GetFullPath( + Path.Combine(Path.GetTempPath(), "bot home")), + }; + + HeadlessPathSet paths = HeadlessPathSet.Resolve( + new HeadlessPathOverrides( + ConfigDirectory: "relative cfg", + DataDirectory: "dåta", + CacheDirectory: "cache"), + platform); + + Assert.Equal( + Path.GetFullPath("relative cfg", platform.CurrentDirectoryValue), + paths.ConfigDirectory); + Assert.Equal( + Path.GetFullPath("dåta", platform.CurrentDirectoryValue), + paths.DataDirectory); + Assert.Equal( + Path.GetFullPath("cache", platform.CurrentDirectoryValue), + paths.CacheDirectory); + } + + [Fact] + public void WindowsUsesRoamingForConfigAndLocalForDataAndCache() + { + string root = Path.GetFullPath( + Path.Combine(Path.GetTempPath(), "acdream-windows")); + var platform = new FixturePlatform(isWindows: true) + { + CurrentDirectoryValue = Path.Combine(root, "work"), + ApplicationData = Path.Combine(root, "AppData", "Roaming"), + LocalApplicationData = Path.Combine(root, "AppData", "Local"), + UserProfile = Path.Combine(root, "Users", "bot"), + }; + + HeadlessPathSet paths = HeadlessPathSet.Resolve( + new HeadlessPathOverrides(), + platform); + + Assert.EndsWith( + Path.Combine("AppData", "Roaming", "acdream"), + paths.ConfigDirectory); + Assert.EndsWith( + Path.Combine("AppData", "Local", "acdream"), + paths.DataDirectory); + Assert.EndsWith( + Path.Combine("AppData", "Local", "acdream", "cache"), + paths.CacheDirectory); + } + + private sealed class FixturePlatform(bool isWindows) + : IHeadlessPlatformEnvironment + { + public bool IsWindows { get; } = isWindows; + public string CurrentDirectoryValue { get; init; } = + Environment.CurrentDirectory; + public string CurrentDirectory => CurrentDirectoryValue; + public string UserProfile { get; init; } = + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + public string ApplicationData { get; init; } = + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); + public string LocalApplicationData { get; init; } = + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + public Dictionary Variables { get; } = + new(StringComparer.Ordinal); + + public string? GetEnvironmentVariable(string name) => + Variables.TryGetValue(name, out string? value) + ? value + : null; + + public string GetFolderPath(Environment.SpecialFolder folder) => + folder switch + { + System.Environment.SpecialFolder.UserProfile => UserProfile, + System.Environment.SpecialFolder.ApplicationData => + ApplicationData, + System.Environment.SpecialFolder.LocalApplicationData => + LocalApplicationData, + _ => string.Empty, + }; + } +} diff --git a/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs b/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs new file mode 100644 index 00000000..29cd5093 --- /dev/null +++ b/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs @@ -0,0 +1,218 @@ +using System.Net; +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; +using AcDream.Headless.Configuration; +using AcDream.Headless.Credentials; +using AcDream.Headless.Diagnostics; +using AcDream.Headless.Hosting; +using AcDream.Headless.Platform; +using AcDream.Runtime; +using AcDream.Runtime.Session; + +namespace AcDream.Headless.Tests; + +public sealed class HeadlessSessionHostTests +{ + [Fact] + public void SingleSessionStartsReconnectsAndConvergesWithoutPresentation() + { + var operations = new FixtureSessionOperations(); + using var diagnosticsOutput = new StringWriter(); + using var credential = new HeadlessCredentialSecret( + "fixture", + "password"); + var host = new HeadlessSessionHost( + Descriptor(), + credential, + new HeadlessDiagnosticWriter(diagnosticsOutput), + operations); + + RuntimeSessionStartResult first = host.Start(); + ulong firstGeneration = host.Runtime.Generation.Value; + host.Tick(0.015d); + RuntimeSessionStartResult second = host.Reconnect(); + + Assert.Equal(RuntimeSessionStartStatus.Connected, first.Status); + Assert.Equal(RuntimeSessionStartStatus.Connected, second.Status); + Assert.Equal(0x50000002u, first.CharacterId); + Assert.Equal("Headless", host.ActiveCharacterName); + Assert.True(host.Runtime.Session.IsInWorld); + Assert.True(host.Runtime.Generation.Value > firstGeneration); + Assert.Equal(2, operations.CreatedSessionCount); + Assert.Equal(1, operations.DisposedSessionCount); + + host.Dispose(); + + Assert.Equal(2, operations.DisposedSessionCount); + Assert.True(host.Runtime.CaptureOwnership().IsConverged); + Assert.True(credential.IsDisposed); + string diagnostics = diagnosticsOutput.ToString(); + Assert.Contains("\"state\":\"start-result\"", diagnostics); + Assert.DoesNotContain("password", diagnostics); + Assert.DoesNotContain("AcDream.App", diagnostics); + } + + [Fact] + public async Task ProcessHostRunsUntilCancellationAndReturnsStableExitCode() + { + var configuration = new HeadlessConfiguration + { + Version = 1, + Sessions = + [ + Descriptor( + HeadlessCredentialProviderKind.StandardInput, + "stdin-bot"), + ], + }; + HeadlessPathSet paths = HeadlessPathSet.Resolve( + new HeadlessPathOverrides()); + using var diagnostics = new StringWriter(); + var operations = new FixtureSessionOperations(); + using var host = new HeadlessProcessHost( + configuration, + paths, + new StringReader("process-password" + Environment.NewLine), + diagnostics, + operations); + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + + HeadlessExitCode result = + await host.RunAsync(cancellation.Token); + + Assert.Equal(HeadlessExitCode.Success, result); + Assert.True(host.Session.Runtime.Session.IsInWorld); + Assert.DoesNotContain( + "process-password", + diagnostics.ToString()); + } + + [Fact] + public void TeardownRetriesOnlyTheUnfinishedSuffix() + { + var operations = new FixtureSessionOperations(); + var writer = new FailOnceTextWriter(); + using var credential = new HeadlessCredentialSecret( + "fixture", + "password"); + var host = new HeadlessSessionHost( + Descriptor(), + credential, + new HeadlessDiagnosticWriter(writer), + operations); + Assert.Equal( + RuntimeSessionStartStatus.Connected, + host.Start().Status); + writer.FailNextWrite = true; + + Assert.Throws(host.Dispose); + Assert.Equal(1, operations.DisposedSessionCount); + + host.Dispose(); + + Assert.Equal(1, operations.DisposedSessionCount); + Assert.True(host.Runtime.CaptureOwnership().IsConverged); + } + + private static HeadlessSessionDescriptor Descriptor( + HeadlessCredentialProviderKind provider = + HeadlessCredentialProviderKind.Environment, + string credentialReference = "BOT_PASSWORD") => new() + { + Id = "bot", + Endpoint = new HeadlessEndpointDescriptor + { + Host = "127.0.0.1", + Port = 9000, + }, + Account = "account", + Character = new HeadlessCharacterSelector + { + Name = "headless", + }, + Policy = new HeadlessBotPolicyDescriptor + { + Id = "idle", + }, + Credential = new HeadlessCredentialReference + { + Provider = provider, + Reference = credentialReference, + }, + }; + + private sealed class FixtureSessionOperations : ILiveSessionOperations + { + public int CreatedSessionCount { get; private set; } + public int DisposedSessionCount { get; private set; } + + public IPEndPoint ResolveEndpoint(string host, int port) => + new(IPAddress.Loopback, port); + + public WorldSession CreateSession(IPEndPoint endpoint) + { + CreatedSessionCount++; + return new WorldSession(endpoint); + } + + public void Connect( + WorldSession session, + string user, + string password) + { + } + + public CharacterList.Parsed GetCharacters( + WorldSession session) => + new( + 0u, + [ + new CharacterList.Character( + 0x50000001u, + "Other", + 0u), + new CharacterList.Character( + 0x50000002u, + "Headless", + 0u), + ], + [], + 11, + "account", + true, + true); + + public void EnterWorld( + WorldSession session, + int activeCharacterIndex) + { + } + + public void Tick(WorldSession session) + { + } + + public void DisposeSession(WorldSession session) + { + DisposedSessionCount++; + session.Dispose(); + } + } + + private sealed class FailOnceTextWriter : StringWriter + { + public bool FailNextWrite { get; set; } + + public override void WriteLine(string? value) + { + if (FailNextWrite) + { + FailNextWrite = false; + throw new IOException("fixture write failure"); + } + base.WriteLine(value); + } + } + +} diff --git a/tests/AcDream.Runtime.Tests/Session/DirectGameRuntimeCommandAdapterTests.cs b/tests/AcDream.Runtime.Tests/Session/DirectGameRuntimeCommandAdapterTests.cs new file mode 100644 index 00000000..7ad40425 --- /dev/null +++ b/tests/AcDream.Runtime.Tests/Session/DirectGameRuntimeCommandAdapterTests.cs @@ -0,0 +1,296 @@ +using System.Net; +using AcDream.Core.Combat; +using AcDream.Core.Items; +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; +using AcDream.Core.Spells; +using AcDream.Runtime.Entities; +using AcDream.Runtime.Gameplay; +using AcDream.Runtime.Session; + +namespace AcDream.Runtime.Tests.Session; + +public sealed class DirectGameRuntimeCommandAdapterTests +{ + [Fact] + public void DirectRouteSendsTypedChatAndPortalAndRejectsOldGeneration() + { + var operations = new FixtureSessionOperations(); + var gameplay = new FixtureGameplayOperations(); + using var runtime = new GameRuntime(new GameRuntimeDependencies( + gameplay, + gameplay, + gameplay, + gameplay, + SessionOperations: operations)); + gameplay.Bind(runtime); + var resetHost = new FixtureResetHost(); + DirectGameRuntimeCommandAdapter? adapter = null; + LiveSessionConnectOptions options = new( + true, + "127.0.0.1", + 9000, + "account", + "password"); + var live = new LiveSessionHost( + runtime.Session, + new LiveSessionHostBindings( + new LiveSessionRoutingFactories( + _ => new FixtureEventRoute(), + session => adapter!.CreateRoute(session)), + generation => runtime.ResetGeneration( + generation, + resetHost), + new LiveSessionSelectionBindings( + id => runtime.PlayerIdentity.ServerGuid = id, + _ => { }, + runtime.CommunicationOwner.Chat.SetLocalPlayerGuid, + _ => { }, + _ => { }, + runtime.ActionOwner.Combat.Clear), + new LiveSessionEnteredWorldBindings( + _ => { }, + () => { }, + () => { }, + _ => { }, + () => { }), + (_, _, _) => { }, + () => { }), + options); + adapter = new DirectGameRuntimeCommandAdapter(runtime, live); + var trace = new RuntimeTraceRecorder(); + using IDisposable subscription = runtime.Subscribe(trace); + + RuntimeSessionStartResult started = + adapter.Session.Start(runtime.Generation); + RuntimeGenerationToken firstGeneration = runtime.Generation; + var gameActions = new List(); + operations.Sessions[^1].GameActionCapture = + body => gameActions.Add(body); + + RuntimeCommandResult chat = adapter.Chat.Execute( + runtime.Generation, + new RuntimeChatCommand( + RuntimeChatChannel.Say, + "hello")); + RuntimeCommandResult portal = adapter.Portal.Execute( + runtime.Generation, + RuntimePortalCommand.RecallLifestone); + RuntimeCommandResult unsupported = adapter.Movement.Execute( + runtime.Generation, + RuntimeMovementCommand.Stop); + + RuntimeSessionStartResult reconnected = + adapter.Session.Reconnect(runtime.Generation); + RuntimeCommandResult stale = adapter.Chat.Execute( + firstGeneration, + new RuntimeChatCommand( + RuntimeChatChannel.Say, + "stale")); + + Assert.Equal(RuntimeSessionStartStatus.Connected, started.Status); + Assert.Equal( + RuntimeSessionStartStatus.Connected, + reconnected.Status); + Assert.True(chat.Accepted); + Assert.True(portal.Accepted); + Assert.Equal( + RuntimeCommandStatus.Unsupported, + unsupported.Status); + Assert.Equal( + RuntimeCommandStatus.StaleGeneration, + stale.Status); + Assert.Equal(2, gameActions.Count); + Assert.Contains( + trace.Entries, + entry => entry.Kind == RuntimeTraceKind.Command + && (entry.Code >> 16) + == (int)RuntimeCommandDomain.Chat + && entry.Text == "hello"); + Assert.Contains( + trace.Entries, + entry => entry.Kind == RuntimeTraceKind.Command + && (entry.Code >> 16) + == (int)RuntimeCommandDomain.Portal); + + RuntimeTeardownAcknowledgement stopped = + adapter.Session.Stop(runtime.Generation); + Assert.True(stopped.IsComplete); + Assert.False(runtime.Session.IsInWorld); + } + + private sealed class FixtureSessionOperations : ILiveSessionOperations + { + public List Sessions { get; } = []; + + public IPEndPoint ResolveEndpoint(string host, int port) => + new(IPAddress.Loopback, port); + + public WorldSession CreateSession(IPEndPoint endpoint) + { + var session = new WorldSession( + endpoint, + new FixtureTransport()); + Sessions.Add(session); + return session; + } + + public void Connect( + WorldSession session, + string user, + string password) + { + } + + public CharacterList.Parsed GetCharacters(WorldSession session) => + new( + 0u, + [ + new CharacterList.Character( + 0x50000001u, + "Direct", + 0u), + ], + [], + 11, + "account", + true, + true); + + public void EnterWorld( + WorldSession session, + int activeCharacterIndex) + { + } + + public void Tick(WorldSession session) + { + } + + public void DisposeSession(WorldSession session) => + session.Dispose(); + } + + private sealed class FixtureEventRoute : ILiveSessionEventRouting + { + public void Attach() + { + } + + public void Dispose() + { + } + } + + private sealed class FixtureResetHost : IRuntimeGenerationResetHost + { + public void RetireEntityProjection(RuntimeEntityRecord entity) + { + } + + public void DrainEntityProjectionBoundary() + { + } + + public void CompleteEntityProjectionRetirement() + { + } + } + + private sealed class FixtureTransport : IWorldSessionTransport + { + public void Send(ReadOnlySpan datagram) + { + } + + public void Send( + IPEndPoint remote, + ReadOnlySpan datagram) + { + } + + public int Receive( + Span destination, + TimeSpan timeout, + out IPEndPoint? from) + { + from = null; + return -1; + } + + public ValueTask ReceiveAsync( + Memory destination, + CancellationToken cancellationToken) => + ValueTask.FromException( + new OperationCanceledException(cancellationToken)); + + public void Dispose() + { + } + } + + private sealed class FixtureGameplayOperations + : IRuntimeCombatAttackOperations, + IRuntimeCombatTargetOperations, + IRuntimeCombatModeOperations, + IRuntimeSpellCastOperations + { + private GameRuntime? _runtime; + + public void Bind(GameRuntime runtime) => _runtime = runtime; + public bool CanStartAttack() => false; + public void PrepareAttackRequest() + { + } + + public bool SendAttack(AttackHeight height, float power) => false; + public void SendCancelAttack() + { + } + + public bool IsDualWield => false; + public bool PlayerReadyForAttack => false; + public bool AutoRepeatAttack => false; + public bool AutoTarget => false; + public uint? SelectClosestTarget() => null; + public bool IsInWorld => _runtime?.Session.IsInWorld == true; + public IReadOnlyList GetOrderedEquipment() => []; + public void NotifyExplicitCombatModeRequest() + { + } + + public void SendChangeCombatMode(CombatMode mode) + { + } + + public uint LocalPlayerId => + _runtime?.PlayerIdentity.ServerGuid ?? 0u; + public bool CanSend => false; + public bool HasRequiredComponents(uint spellId) => false; + + public bool IsTargetCompatible( + uint targetId, + SpellMetadata spell, + bool showMessage) => false; + + public void StopCompletely() + { + } + + public void SendUntargeted(uint spellId) + { + } + + public void SendTargeted(uint targetId, uint spellId) + { + } + + public void DisplayMessage(string message) + { + } + + public void IncrementBusy() + { + } + } +} diff --git a/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerTests.cs b/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerTests.cs index e377ed31..eaa6703d 100644 --- a/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerTests.cs +++ b/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerTests.cs @@ -351,6 +351,53 @@ public sealed class LiveSessionControllerTests Assert.Equal(1, operations.DisposeCounts[operations.Sessions[0]]); } + [Theory] + [InlineData("index")] + [InlineData("id")] + [InlineData("name")] + public void Start_SelectsConfiguredAvailableCharacter(string selectorKind) + { + var calls = new List(); + var operations = new TestOperations(calls); + var host = new TestHost(calls); + var controller = new LiveSessionController(operations); + LiveSessionCharacterSelector selector = selectorKind switch + { + "index" => new(ActiveIndex: 1), + "id" => new(CharacterId: 0x50000002u), + "name" => new(CharacterName: "ready"), + _ => throw new ArgumentOutOfRangeException(nameof(selectorKind)), + }; + + LiveSessionStartResult result = controller.Start( + LiveOptions(selector: selector), + host); + + Assert.Equal(LiveSessionStartStatus.Connected, result.Status); + Assert.Equal(0x50000002u, result.Selection!.CharacterId); + Assert.Contains("enter:1", calls); + } + + [Fact] + public void Start_UnavailableConfiguredCharacterConvergesOffline() + { + var calls = new List(); + var operations = new TestOperations(calls); + var host = new TestHost(calls); + var controller = new LiveSessionController(operations); + + LiveSessionStartResult result = controller.Start( + LiveOptions( + selector: new LiveSessionCharacterSelector( + CharacterId: 0x50000001u)), + host); + + Assert.Equal(LiveSessionStartStatus.NoCharacters, result.Status); + Assert.False(controller.IsInWorld); + Assert.Null(controller.CurrentSession); + Assert.Equal(1, operations.DisposeCounts[operations.Sessions[0]]); + } + [Fact] public void Start_ConnectFailureConvergesOffline() { @@ -926,13 +973,15 @@ public sealed class LiveSessionControllerTests private static LiveSessionConnectOptions LiveOptions( bool live = true, - string? user = "user") => + string? user = "user", + LiveSessionCharacterSelector? selector = null) => new( live, "127.0.0.1", 9000, user ?? string.Empty, - "password"); + "password", + selector); private static CharacterList.Parsed AvailableCharacters() => new( 0u, diff --git a/tests/AcDream.Runtime.Tests/Session/RuntimeLiveEntitySessionControllerTests.cs b/tests/AcDream.Runtime.Tests/Session/RuntimeLiveEntitySessionControllerTests.cs new file mode 100644 index 00000000..99e43d9d --- /dev/null +++ b/tests/AcDream.Runtime.Tests/Session/RuntimeLiveEntitySessionControllerTests.cs @@ -0,0 +1,278 @@ +using System.Net; +using AcDream.Core.Combat; +using AcDream.Core.Items; +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; +using AcDream.Core.Physics; +using AcDream.Core.Spells; +using AcDream.Runtime.Entities; +using AcDream.Runtime.Gameplay; +using AcDream.Runtime.Session; + +namespace AcDream.Runtime.Tests.Session; + +public sealed class RuntimeLiveEntitySessionControllerTests +{ + [Fact] + public void DirectSinkOwnsCanonicalCreateUpdateDeleteWithoutProjection() + { + using GameRuntime runtime = CreateRuntime(); + using var session = new WorldSession( + new IPEndPoint(IPAddress.Loopback, 9000), + new FixtureTransport()); + var controller = new RuntimeLiveEntitySessionController( + runtime, + session); + LiveEntitySessionSink sink = controller.CreateSink(); + WorldSession.EntitySpawn spawn = + Spawn(0x70000001u, incarnation: 1); + + sink.Spawned(spawn); + sink.PositionUpdated(new WorldSession.EntityPositionUpdate( + spawn.Guid, + spawn.Position!.Value with + { + PositionX = 20f, + }, + Velocity: null, + PlacementId: null, + IsGrounded: true, + InstanceSequence: 1, + PositionSequence: 2, + TeleportSequence: 0, + ForcePositionSequence: 0)); + + Assert.Equal(1, runtime.Entities.Count); + Assert.Equal(1, runtime.Inventory.ObjectCount); + Assert.True( + runtime.EntityObjects.Entities.TryGetActive( + spawn.Guid, + out RuntimeEntityRecord canonical)); + Assert.Equal( + 20f, + canonical.Snapshot.Position!.Value.PositionX); + + sink.Deleted(new DeleteObject.Parsed(spawn.Guid, 1)); + + Assert.Equal(0, runtime.Entities.Count); + Assert.Equal(0, runtime.Inventory.ObjectCount); + Assert.Equal( + 0, + runtime.EntityObjects.Entities.PendingTeardownCount); + } + + [Fact] + public void DirectSinkCompletesExactPortalAndSendsLoginComplete() + { + using GameRuntime runtime = CreateRuntime(); + const uint playerGuid = 0x50000001u; + runtime.PlayerIdentity.ServerGuid = playerGuid; + using var session = new WorldSession( + new IPEndPoint(IPAddress.Loopback, 9000), + new FixtureTransport()); + var gameActions = new List(); + session.GameActionCapture = body => gameActions.Add(body); + var controller = new RuntimeLiveEntitySessionController( + runtime, + session); + LiveEntitySessionSink sink = controller.CreateSink(); + WorldSession.EntitySpawn spawn = + Spawn(playerGuid, incarnation: 1); + sink.Spawned(spawn); + + sink.TeleportStarted(1u); + sink.PositionUpdated(new WorldSession.EntityPositionUpdate( + playerGuid, + spawn.Position!.Value with + { + LandblockId = 0x01020001u, + PositionX = 30f, + }, + Velocity: null, + PlacementId: null, + IsGrounded: true, + InstanceSequence: 1, + PositionSequence: 2, + TeleportSequence: 1, + ForcePositionSequence: 0)); + + Assert.Single(gameActions); + Assert.True(runtime.TransitOwner.CaptureOwnership().IsSessionIdle); + Assert.True(runtime.Portal.Snapshot.Completed); + Assert.Equal(0x01020001u, runtime.Portal.Snapshot.DestinationCell); + } + + private static GameRuntime CreateRuntime() + { + var operations = new FixtureGameplayOperations(); + var runtime = new GameRuntime(new GameRuntimeDependencies( + operations, + operations, + operations, + operations)); + operations.Bind(runtime); + return runtime; + } + + private static WorldSession.EntitySpawn Spawn( + uint guid, + ushort incarnation) + { + var position = new CreateObject.ServerPosition( + 0x01010001u, + 10f, + 10f, + 5f, + 1f, + 0f, + 0f, + 0f); + var timestamps = new PhysicsTimestamps( + Position: 1, + Movement: 1, + State: 1, + Vector: 1, + Teleport: 0, + ServerControlledMove: 1, + ForcePosition: 0, + ObjDesc: 1, + Instance: incarnation); + var physics = new PhysicsSpawnData( + RawState: (uint)PhysicsStateFlags.ReportCollisions, + Position: position, + Movement: null, + AnimationFrame: null, + SetupTableId: 0x02000001u, + MotionTableId: null, + SoundTableId: null, + PhysicsScriptTableId: null, + Parent: null, + Children: null, + Scale: null, + Friction: null, + Elasticity: null, + Translucency: null, + Velocity: null, + Acceleration: null, + AngularVelocity: null, + DefaultScriptType: null, + DefaultScriptIntensity: null, + Timestamps: timestamps); + return new WorldSession.EntitySpawn( + guid, + position, + 0x02000001u, + [], + [], + [], + null, + null, + "direct entity", + null, + null, + null, + PhysicsState: physics.RawState, + InstanceSequence: incarnation, + MovementSequence: 1, + ServerControlSequence: 1, + PositionSequence: 1, + Physics: physics); + } + + private sealed class FixtureTransport : IWorldSessionTransport + { + public void Send(ReadOnlySpan datagram) + { + } + + public void Send( + IPEndPoint remote, + ReadOnlySpan datagram) + { + } + + public int Receive( + Span destination, + TimeSpan timeout, + out IPEndPoint? from) + { + from = null; + return -1; + } + + public ValueTask ReceiveAsync( + Memory destination, + CancellationToken cancellationToken) => + ValueTask.FromException( + new OperationCanceledException(cancellationToken)); + + public void Dispose() + { + } + } + + private sealed class FixtureGameplayOperations + : IRuntimeCombatAttackOperations, + IRuntimeCombatTargetOperations, + IRuntimeCombatModeOperations, + IRuntimeSpellCastOperations + { + private GameRuntime? _runtime; + + public void Bind(GameRuntime runtime) => _runtime = runtime; + public bool CanStartAttack() => false; + public void PrepareAttackRequest() + { + } + + public bool SendAttack(AttackHeight height, float power) => false; + public void SendCancelAttack() + { + } + + public bool IsDualWield => false; + public bool PlayerReadyForAttack => false; + public bool AutoRepeatAttack => false; + public bool AutoTarget => false; + public uint? SelectClosestTarget() => null; + public bool IsInWorld => _runtime?.Session.IsInWorld == true; + public IReadOnlyList GetOrderedEquipment() => []; + public void NotifyExplicitCombatModeRequest() + { + } + + public void SendChangeCombatMode(CombatMode mode) + { + } + + public uint LocalPlayerId => + _runtime?.PlayerIdentity.ServerGuid ?? 0u; + public bool CanSend => false; + public bool HasRequiredComponents(uint spellId) => false; + + public bool IsTargetCompatible( + uint targetId, + SpellMetadata spell, + bool showMessage) => false; + + public void StopCompletely() + { + } + + public void SendUntargeted(uint spellId) + { + } + + public void SendTargeted(uint targetId, uint spellId) + { + } + + public void DisplayMessage(string message) + { + } + + public void IncrementBusy() + { + } + } +}