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]
public int Version { get; init; }
public HeadlessProcessSettings Process { get; init; } = new();
[JsonRequired]
public List<HeadlessSessionDescriptor?> Sessions { get; init; } = [];
}
internal sealed class HeadlessProcessSettings
{
public HeadlessPathOverrides Paths { get; init; } = new();
}
internal sealed class HeadlessSessionDescriptor
{
[JsonRequired]
public string Id { get; init; } = string.Empty;
[JsonRequired]
public HeadlessEndpointDescriptor Endpoint { get; init; } = new();
[JsonRequired]
public string Account { get; init; } = string.Empty;
[JsonRequired]
public HeadlessCharacterSelector Character { get; init; } = new();
[JsonRequired]
public HeadlessBotPolicyDescriptor Policy { get; init; } = new();
[JsonRequired]
public HeadlessCredentialReference Credential { get; init; } = new();
}
internal sealed class HeadlessEndpointDescriptor
{
[JsonRequired]
public string Host { get; init; } = string.Empty;
[JsonRequired]
public int Port { get; init; }
}
internal sealed class HeadlessCharacterSelector
{
public int? Index { get; init; }
public uint? Id { get; init; }
public string? Name { get; init; }
}
internal sealed class HeadlessBotPolicyDescriptor
{
[JsonRequired]
public string Id { get; init; } = string.Empty;
}
[JsonConverter(typeof(JsonStringEnumConverter<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,
ReadCommentHandling = JsonCommentHandling.Disallow,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow,
Converters =
{
new JsonStringEnumConverter(
JsonNamingPolicy.CamelCase,
allowIntegerValues: false),
},
};
internal static HeadlessConfiguration Load(string path)
@ -47,6 +53,8 @@ internal static class HeadlessConfigurationLoader
}
var sessionIds = new HashSet<string>(StringComparer.Ordinal);
var credentialReferences = new HashSet<string>(
StringComparer.Ordinal);
foreach (HeadlessSessionDescriptor? session in configuration.Sessions)
{
if (session is null
@ -61,8 +69,66 @@ internal static class HeadlessConfigurationLoader
throw new HeadlessConfigurationException(
$"Duplicate session id '{session.Id}'.");
}
ValidateSession(session);
string credentialKey =
$"{session.Credential.Provider}:{session.Credential.Reference}";
if (!credentialReferences.Add(credentialKey))
{
throw new HeadlessConfigurationException(
$"Credential reference for session '{session.Id}' is already in use.");
}
}
return configuration;
}
private static void ValidateSession(HeadlessSessionDescriptor session)
{
if (session.Endpoint is null
|| string.IsNullOrWhiteSpace(session.Endpoint.Host)
|| session.Endpoint.Port is < 1 or > 65535)
{
throw new HeadlessConfigurationException(
$"Session '{session.Id}' requires a host and a port from 1 through 65535.");
}
if (string.IsNullOrWhiteSpace(session.Account))
{
throw new HeadlessConfigurationException(
$"Session '{session.Id}' requires a non-empty account.");
}
if (session.Character is null)
{
throw new HeadlessConfigurationException(
$"Session '{session.Id}' requires a character selector.");
}
int selectorCount =
(session.Character.Index.HasValue ? 1 : 0)
+ (session.Character.Id.HasValue ? 1 : 0)
+ (!string.IsNullOrWhiteSpace(session.Character.Name) ? 1 : 0);
if (selectorCount != 1
|| session.Character.Index is < 0
|| session.Character.Id == 0u)
{
throw new HeadlessConfigurationException(
$"Session '{session.Id}' character selector must specify exactly one valid index, id, or name.");
}
if (session.Policy is null
|| string.IsNullOrWhiteSpace(session.Policy.Id))
{
throw new HeadlessConfigurationException(
$"Session '{session.Id}' requires a non-empty policy id.");
}
if (session.Credential is null
|| string.IsNullOrWhiteSpace(session.Credential.Reference))
{
throw new HeadlessConfigurationException(
$"Session '{session.Id}' requires a credential reference.");
}
}
}

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.Credentials;
using AcDream.Headless.Hosting;
using AcDream.Headless.Platform;
namespace AcDream.Headless;
@ -10,10 +13,17 @@ internal static class HeadlessEntryPoint
Usage:
acdream-headless --help
acdream-headless validate --config <path>
acdream-headless validate --config <path> [path overrides]
acdream-headless run --config <path> [path overrides]
Commands:
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.
""";
@ -21,9 +31,23 @@ internal static class HeadlessEntryPoint
internal static int Run(
IReadOnlyList<string> arguments,
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(standardInput);
ArgumentNullException.ThrowIfNull(output);
ArgumentNullException.ThrowIfNull(error);
@ -34,46 +58,67 @@ internal static class HeadlessEntryPoint
return (int)HeadlessExitCode.Success;
}
if (!string.Equals(
arguments[0],
"validate",
StringComparison.Ordinal))
{
error.WriteLine("Unknown command. Run --help for usage.");
return (int)HeadlessExitCode.UsageError;
}
if (arguments.Count != 3
|| !string.Equals(
arguments[1],
"--config",
StringComparison.Ordinal))
{
error.WriteLine(
"validate requires exactly: --config <path>");
return (int)HeadlessExitCode.UsageError;
}
try
{
HeadlessCommandLine commandLine =
HeadlessCommandLine.Parse(arguments);
HeadlessConfiguration configuration =
HeadlessConfigurationLoader.Load(arguments[2]);
HeadlessConfigurationLoader.Load(
commandLine.ConfigurationPath);
HeadlessPathOverrides configuredPaths =
configuration.Process?.Paths
?? new HeadlessPathOverrides();
HeadlessPathSet paths = HeadlessPathSet.Resolve(
configuredPaths.Merge(commandLine.Paths));
if (commandLine.Command == "run")
{
using var host = new HeadlessProcessHost(
configuration,
paths,
standardInput,
output);
return (int)host.RunAsync(cancellationToken)
.GetAwaiter()
.GetResult();
}
output.WriteLine(
$"Configuration valid: version {configuration.Version}, "
+ $"{configuration.Sessions.Count} session(s).");
return (int)HeadlessExitCode.Success;
}
catch (HeadlessCommandLineException)
{
error.WriteLine("Invalid command. Run --help for usage.");
return (int)HeadlessExitCode.UsageError;
}
catch (System.Text.Json.JsonException)
{
error.WriteLine(
"Configuration invalid: the JSON document is invalid.");
return (int)HeadlessExitCode.ConfigurationError;
}
catch (Exception exception)
when (exception is IOException
or UnauthorizedAccessException
or ArgumentException
or NotSupportedException
or System.Text.Json.JsonException
or HeadlessConfigurationException)
{
error.WriteLine($"Configuration invalid: {exception.Message}");
return (int)HeadlessExitCode.ConfigurationError;
}
catch (HeadlessCredentialException exception)
{
error.WriteLine($"Credential unavailable: {exception.Message}");
return (int)HeadlessExitCode.CredentialError;
}
catch (Exception exception)
{
error.WriteLine(
$"Headless runtime failed: {exception.GetType().FullName}");
return (int)HeadlessExitCode.RuntimeError;
}
}
private static bool IsHelp(string argument) =>

View file

@ -5,4 +5,7 @@ internal enum HeadlessExitCode
Success = 0,
UsageError = 2,
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;
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;
public sealed record LiveSessionCharacterSelector(
int? ActiveIndex = null,
uint? CharacterId = null,
string? CharacterName = null);
public sealed record LiveSessionConnectOptions(
bool Enabled,
string Host,
int Port,
string User,
string Password);
string Password,
LiveSessionCharacterSelector? Character = null);
public interface IRuntimeLiveSessionFramePhase
{

View file

@ -556,7 +556,10 @@ public sealed class LiveSessionController
CharacterList.Parsed? characters = _operations.GetCharacters(session);
if (characters is null
|| !CharacterList.TrySelectFirstAvailable(characters, out CharacterList.Selection selected))
|| !TrySelectCharacter(
characters,
options.Character,
out CharacterList.Selection selected))
{
Console.WriteLine("live: no available characters on account; disconnecting");
StopCore();
@ -755,6 +758,62 @@ public sealed class LiveSessionController
private LiveSessionStartResult ConnectedResult()
=> new(LiveSessionStartStatus.Connected, _activeSelection);
private static bool TrySelectCharacter(
CharacterList.Parsed characters,
LiveSessionCharacterSelector? selector,
out CharacterList.Selection selection)
{
ArgumentNullException.ThrowIfNull(characters);
if (selector is null)
{
return CharacterList.TrySelectFirstAvailable(
characters,
out selection);
}
int selectorCount =
(selector.ActiveIndex.HasValue ? 1 : 0)
+ (selector.CharacterId.HasValue ? 1 : 0)
+ (!string.IsNullOrWhiteSpace(selector.CharacterName) ? 1 : 0);
if (selectorCount != 1)
{
selection = default;
return false;
}
for (int index = 0; index < characters.Characters.Count; index++)
{
CharacterList.Character character =
characters.Characters[index];
if (!CharacterList.IsAvailableActiveIdentity(character))
continue;
if (selector.ActiveIndex is { } requestedIndex
&& requestedIndex != index)
{
continue;
}
if (selector.CharacterId is { } requestedId
&& requestedId != character.Id)
{
continue;
}
if (selector.CharacterName is { } requestedName
&& !string.Equals(
requestedName,
character.Name,
StringComparison.OrdinalIgnoreCase))
{
continue;
}
selection = new CharacterList.Selection(index, character);
return true;
}
selection = default;
return false;
}
private void ThrowIfDisposing()
{
if (_disposeRequested || _disposed)

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}.");
}
}
}