feat(headless): complete portable single-session host

This commit is contained in:
Erik 2026-07-27 07:36:53 +02:00
parent fbebb91848
commit f8cb840fb1
30 changed files with 3571 additions and 32 deletions

View file

@ -0,0 +1,83 @@
namespace AcDream.Headless.Configuration;
internal sealed record HeadlessCommandLine(
string Command,
string ConfigurationPath,
HeadlessPathOverrides Paths)
{
internal static HeadlessCommandLine Parse(
IReadOnlyList<string> 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;
}
}

View file

@ -0,0 +1,9 @@
namespace AcDream.Headless.Configuration;
internal sealed class HeadlessCommandLineException : Exception
{
internal HeadlessCommandLineException(string message)
: base(message)
{
}
}

View file

@ -7,12 +7,73 @@ internal sealed class HeadlessConfiguration
[JsonRequired] [JsonRequired]
public int Version { get; init; } public int Version { get; init; }
public HeadlessProcessSettings Process { get; init; } = new();
[JsonRequired] [JsonRequired]
public List<HeadlessSessionDescriptor?> Sessions { get; init; } = []; public List<HeadlessSessionDescriptor?> Sessions { get; init; } = [];
} }
internal sealed class HeadlessProcessSettings
{
public HeadlessPathOverrides Paths { get; init; } = new();
}
internal sealed class HeadlessSessionDescriptor internal sealed class HeadlessSessionDescriptor
{ {
[JsonRequired] [JsonRequired]
public string Id { get; init; } = string.Empty; 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<HeadlessCredentialProviderKind>))]
internal enum HeadlessCredentialProviderKind
{
Environment,
StandardInput,
File,
}
internal sealed class HeadlessCredentialReference
{
[JsonRequired]
public HeadlessCredentialProviderKind Provider { get; init; }
[JsonRequired]
public string Reference { get; init; } = string.Empty;
} }

View file

@ -14,6 +14,12 @@ internal static class HeadlessConfigurationLoader
PropertyNamingPolicy = JsonNamingPolicy.CamelCase, PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
ReadCommentHandling = JsonCommentHandling.Disallow, ReadCommentHandling = JsonCommentHandling.Disallow,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow, UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow,
Converters =
{
new JsonStringEnumConverter(
JsonNamingPolicy.CamelCase,
allowIntegerValues: false),
},
}; };
internal static HeadlessConfiguration Load(string path) internal static HeadlessConfiguration Load(string path)
@ -47,6 +53,8 @@ internal static class HeadlessConfigurationLoader
} }
var sessionIds = new HashSet<string>(StringComparer.Ordinal); var sessionIds = new HashSet<string>(StringComparer.Ordinal);
var credentialReferences = new HashSet<string>(
StringComparer.Ordinal);
foreach (HeadlessSessionDescriptor? session in configuration.Sessions) foreach (HeadlessSessionDescriptor? session in configuration.Sessions)
{ {
if (session is null if (session is null
@ -61,8 +69,66 @@ internal static class HeadlessConfigurationLoader
throw new HeadlessConfigurationException( throw new HeadlessConfigurationException(
$"Duplicate session id '{session.Id}'."); $"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; 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.");
}
}
} }

View file

@ -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);
}

View file

@ -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)
{
}
}

View file

@ -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;
}
}

View file

@ -0,0 +1,50 @@
using System.Security.Cryptography;
namespace AcDream.Headless.Credentials;
/// <summary>
/// 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.
/// </summary>
internal sealed class HeadlessCredentialSecret : IDisposable
{
private char[]? _buffer;
internal HeadlessCredentialSecret(
string referenceId,
ReadOnlySpan<char> 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}]";
}

View file

@ -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>(T value)
{
_output.WriteLine(JsonSerializer.Serialize(value, JsonOptions));
_output.Flush();
}
}

View file

@ -1,4 +1,7 @@
using AcDream.Headless.Configuration; using AcDream.Headless.Configuration;
using AcDream.Headless.Credentials;
using AcDream.Headless.Hosting;
using AcDream.Headless.Platform;
namespace AcDream.Headless; namespace AcDream.Headless;
@ -10,10 +13,17 @@ internal static class HeadlessEntryPoint
Usage: Usage:
acdream-headless --help acdream-headless --help
acdream-headless validate --config <path> acdream-headless validate --config <path> [path overrides]
acdream-headless run --config <path> [path overrides]
Commands: Commands:
validate Validate a versioned headless configuration without connecting. validate Validate a versioned headless configuration without connecting.
run Run exactly one configured no-window session.
Path overrides:
--config-dir <path>
--data-dir <path>
--cache-dir <path>
Secrets are never accepted on the command line. Secrets are never accepted on the command line.
"""; """;
@ -21,9 +31,23 @@ internal static class HeadlessEntryPoint
internal static int Run( internal static int Run(
IReadOnlyList<string> arguments, IReadOnlyList<string> arguments,
TextWriter output, TextWriter output,
TextWriter error) TextWriter error) =>
Run(
arguments,
TextReader.Null,
output,
error,
CancellationToken.None);
internal static int Run(
IReadOnlyList<string> arguments,
TextReader standardInput,
TextWriter output,
TextWriter error,
CancellationToken cancellationToken)
{ {
ArgumentNullException.ThrowIfNull(arguments); ArgumentNullException.ThrowIfNull(arguments);
ArgumentNullException.ThrowIfNull(standardInput);
ArgumentNullException.ThrowIfNull(output); ArgumentNullException.ThrowIfNull(output);
ArgumentNullException.ThrowIfNull(error); ArgumentNullException.ThrowIfNull(error);
@ -34,46 +58,67 @@ internal static class HeadlessEntryPoint
return (int)HeadlessExitCode.Success; 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 <path>");
return (int)HeadlessExitCode.UsageError;
}
try try
{ {
HeadlessCommandLine commandLine =
HeadlessCommandLine.Parse(arguments);
HeadlessConfiguration configuration = 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( output.WriteLine(
$"Configuration valid: version {configuration.Version}, " $"Configuration valid: version {configuration.Version}, "
+ $"{configuration.Sessions.Count} session(s)."); + $"{configuration.Sessions.Count} session(s).");
return (int)HeadlessExitCode.Success; 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) catch (Exception exception)
when (exception is IOException when (exception is IOException
or UnauthorizedAccessException or UnauthorizedAccessException
or ArgumentException or ArgumentException
or NotSupportedException or NotSupportedException
or System.Text.Json.JsonException
or HeadlessConfigurationException) or HeadlessConfigurationException)
{ {
error.WriteLine($"Configuration invalid: {exception.Message}"); error.WriteLine($"Configuration invalid: {exception.Message}");
return (int)HeadlessExitCode.ConfigurationError; 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) => private static bool IsHelp(string argument) =>

View file

@ -5,4 +5,7 @@ internal enum HeadlessExitCode
Success = 0, Success = 0,
UsageError = 2, UsageError = 2,
ConfigurationError = 3, ConfigurationError = 3,
CredentialError = 4,
ConnectionError = 5,
RuntimeError = 6,
} }

View file

@ -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;
/// <summary>
/// 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.
/// </summary>
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<ClientObject> 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()
{
}
}

View file

@ -0,0 +1,25 @@
using AcDream.Runtime;
using AcDream.Runtime.Entities;
namespace AcDream.Headless.Hosting;
/// <summary>
/// A direct host owns no graphical projections, but still acknowledges every
/// exact canonical retirement edge required by Runtime generation reset.
/// </summary>
internal sealed class HeadlessGenerationResetHost
: IRuntimeGenerationResetHost
{
public void RetireEntityProjection(RuntimeEntityRecord entity)
{
ArgumentNullException.ThrowIfNull(entity);
}
public void DrainEntityProjectionBoundary()
{
}
public void CompleteEntityProjectionRetirement()
{
}
}

View file

@ -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<HeadlessExitCode> 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;
}
}

View file

@ -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);
}
}

View file

@ -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));
}
}

View file

@ -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);
}

View file

@ -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()
{
}
}
/// <summary>
/// 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.
/// </summary>
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}.");
}
}
}

View file

@ -1,3 +1,36 @@
using System.Runtime.InteropServices;
using AcDream.Headless; 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;
}

View file

@ -0,0 +1,427 @@
using AcDream.Core.Net;
using AcDream.Runtime.Gameplay;
namespace AcDream.Runtime.Session;
/// <summary>
/// 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.
/// </summary>
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;
}
}
}

View file

@ -2,12 +2,18 @@ using AcDream.Core.Net;
namespace AcDream.Runtime.Session; namespace AcDream.Runtime.Session;
public sealed record LiveSessionCharacterSelector(
int? ActiveIndex = null,
uint? CharacterId = null,
string? CharacterName = null);
public sealed record LiveSessionConnectOptions( public sealed record LiveSessionConnectOptions(
bool Enabled, bool Enabled,
string Host, string Host,
int Port, int Port,
string User, string User,
string Password); string Password,
LiveSessionCharacterSelector? Character = null);
public interface IRuntimeLiveSessionFramePhase public interface IRuntimeLiveSessionFramePhase
{ {

View file

@ -556,7 +556,10 @@ public sealed class LiveSessionController
CharacterList.Parsed? characters = _operations.GetCharacters(session); CharacterList.Parsed? characters = _operations.GetCharacters(session);
if (characters is null 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"); Console.WriteLine("live: no available characters on account; disconnecting");
StopCore(); StopCore();
@ -755,6 +758,62 @@ public sealed class LiveSessionController
private LiveSessionStartResult ConnectedResult() private LiveSessionStartResult ConnectedResult()
=> new(LiveSessionStartStatus.Connected, _activeSelection); => 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() private void ThrowIfDisposing()
{ {
if (_disposeRequested || _disposed) if (_disposeRequested || _disposed)

View file

@ -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;
/// <summary>
/// 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.
/// </summary>
public sealed class RuntimeLiveEntitySessionController
{
private readonly GameRuntime _runtime;
private readonly WorldSession _session;
private readonly Action<string> _log;
public RuntimeLiveEntitySessionController(
GameRuntime runtime,
WorldSession session,
Action<string>? 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}.");
}
}
}

View file

@ -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<string, string>
{
["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<ObjectDisposedException>(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<string, string>()));
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<string, string>()));
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<string, string>
{
["OTHER"] = unrelatedSecret,
}));
HeadlessCredentialException error =
Assert.Throws<HeadlessCredentialException>(() =>
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<string, string>()));
Assert.Throws<HeadlessCredentialException>(() =>
resolver.Resolve(
"bot",
new HeadlessCredentialReference
{
Provider = HeadlessCredentialProviderKind.File,
Reference = Path.GetFileName(path),
}));
}
finally
{
File.Delete(path);
}
}
private sealed class FixtureCredentialEnvironment(
bool isLinux,
IReadOnlyDictionary<string, string> values)
: IHeadlessCredentialEnvironment
{
public bool IsLinux { get; } = isLinux;
public string? GetEnvironmentVariable(string name) =>
values.TryGetValue(name, out string? value)
? value
: null;
}
}

View file

@ -41,10 +41,9 @@ public sealed class HeadlessEntryPointTests
[Theory] [Theory]
[InlineData("""{"version":2,"sessions":[]}""", "Unsupported configuration version")] [InlineData("""{"version":2,"sessions":[]}""", "Unsupported configuration version")]
[InlineData("""{"version":1,"sessions":[],"password":"secret"}""", "could not be mapped")] [InlineData("""{"version":1,"sessions":[],"password":"secret"}""", "JSON document is invalid")]
[InlineData("""{"version":1,"sessions":[{"id":"bot"},{"id":"bot"}]}""", "Duplicate session id")]
[InlineData("""{"version":1,"sessions":[null]}""", "non-empty id")] [InlineData("""{"version":1,"sessions":[null]}""", "non-empty id")]
[InlineData("""{"version":1}""", "required properties")] [InlineData("""{"version":1}""", "JSON document is invalid")]
public void ValidateRejectsInvalidOrSecretShapedConfiguration( public void ValidateRejectsInvalidOrSecretShapedConfiguration(
string json, string json,
string expectedError) string expectedError)
@ -67,6 +66,51 @@ public sealed class HeadlessEntryPointTests
Assert.Equal(string.Empty, output.ToString()); 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] [Fact]
public void UnknownCommandReturnsUsageError() public void UnknownCommandReturnsUsageError()
{ {
@ -83,6 +127,15 @@ public sealed class HeadlessEntryPointTests
Assert.Equal(string.Empty, output.ToString()); 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 sealed class TemporaryConfiguration : IDisposable
{ {
private TemporaryConfiguration(string path) private TemporaryConfiguration(string path)

View file

@ -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<string, string> 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,
};
}
}

View file

@ -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<IOException>(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);
}
}
}

View file

@ -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<byte[]>();
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<WorldSession> 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<byte> datagram)
{
}
public void Send(
IPEndPoint remote,
ReadOnlySpan<byte> datagram)
{
}
public int Receive(
Span<byte> destination,
TimeSpan timeout,
out IPEndPoint? from)
{
from = null;
return -1;
}
public ValueTask<NetReceiveResult> ReceiveAsync(
Memory<byte> destination,
CancellationToken cancellationToken) =>
ValueTask.FromException<NetReceiveResult>(
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<ClientObject> 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()
{
}
}
}

View file

@ -351,6 +351,53 @@ public sealed class LiveSessionControllerTests
Assert.Equal(1, operations.DisposeCounts[operations.Sessions[0]]); 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<string>();
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<string>();
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] [Fact]
public void Start_ConnectFailureConvergesOffline() public void Start_ConnectFailureConvergesOffline()
{ {
@ -926,13 +973,15 @@ public sealed class LiveSessionControllerTests
private static LiveSessionConnectOptions LiveOptions( private static LiveSessionConnectOptions LiveOptions(
bool live = true, bool live = true,
string? user = "user") => string? user = "user",
LiveSessionCharacterSelector? selector = null) =>
new( new(
live, live,
"127.0.0.1", "127.0.0.1",
9000, 9000,
user ?? string.Empty, user ?? string.Empty,
"password"); "password",
selector);
private static CharacterList.Parsed AvailableCharacters() => new( private static CharacterList.Parsed AvailableCharacters() => new(
0u, 0u,

View file

@ -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<byte[]>();
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<byte> datagram)
{
}
public void Send(
IPEndPoint remote,
ReadOnlySpan<byte> datagram)
{
}
public int Receive(
Span<byte> destination,
TimeSpan timeout,
out IPEndPoint? from)
{
from = null;
return -1;
}
public ValueTask<NetReceiveResult> ReceiveAsync(
Memory<byte> destination,
CancellationToken cancellationToken) =>
ValueTask.FromException<NetReceiveResult>(
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<ClientObject> 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()
{
}
}
}