feat(headless): complete portable single-session host
This commit is contained in:
parent
fbebb91848
commit
f8cb840fb1
30 changed files with 3571 additions and 32 deletions
83
src/AcDream.Headless/Configuration/HeadlessCommandLine.cs
Normal file
83
src/AcDream.Headless/Configuration/HeadlessCommandLine.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
namespace AcDream.Headless.Configuration;
|
||||||
|
|
||||||
|
internal sealed class HeadlessCommandLineException : Exception
|
||||||
|
{
|
||||||
|
internal HeadlessCommandLineException(string message)
|
||||||
|
: base(message)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
13
src/AcDream.Headless/Configuration/HeadlessPathOverrides.cs
Normal file
13
src/AcDream.Headless/Configuration/HeadlessPathOverrides.cs
Normal 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);
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
159
src/AcDream.Headless/Credentials/HeadlessCredentialResolver.cs
Normal file
159
src/AcDream.Headless/Credentials/HeadlessCredentialResolver.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
50
src/AcDream.Headless/Credentials/HeadlessCredentialSecret.cs
Normal file
50
src/AcDream.Headless/Credentials/HeadlessCredentialSecret.cs
Normal 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}]";
|
||||||
|
}
|
||||||
80
src/AcDream.Headless/Diagnostics/HeadlessDiagnosticWriter.cs
Normal file
80
src/AcDream.Headless/Diagnostics/HeadlessDiagnosticWriter.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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) =>
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
80
src/AcDream.Headless/Hosting/HeadlessGameplayOperations.cs
Normal file
80
src/AcDream.Headless/Hosting/HeadlessGameplayOperations.cs
Normal 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()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
25
src/AcDream.Headless/Hosting/HeadlessGenerationResetHost.cs
Normal file
25
src/AcDream.Headless/Hosting/HeadlessGenerationResetHost.cs
Normal 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()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
115
src/AcDream.Headless/Hosting/HeadlessProcessHost.cs
Normal file
115
src/AcDream.Headless/Hosting/HeadlessProcessHost.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
414
src/AcDream.Headless/Hosting/HeadlessSessionHost.cs
Normal file
414
src/AcDream.Headless/Hosting/HeadlessSessionHost.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
99
src/AcDream.Headless/Platform/HeadlessPathSet.cs
Normal file
99
src/AcDream.Headless/Platform/HeadlessPathSet.cs
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
28
src/AcDream.Headless/Platform/HeadlessPlatformEnvironment.cs
Normal file
28
src/AcDream.Headless/Platform/HeadlessPlatformEnvironment.cs
Normal 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);
|
||||||
|
}
|
||||||
181
src/AcDream.Headless/Policies/HeadlessBotPolicy.cs
Normal file
181
src/AcDream.Headless/Policies/HeadlessBotPolicy.cs
Normal 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}.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
427
src/AcDream.Runtime/Session/DirectGameRuntimeCommandAdapter.cs
Normal file
427
src/AcDream.Runtime/Session/DirectGameRuntimeCommandAdapter.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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}.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
173
tests/AcDream.Headless.Tests/HeadlessCredentialResolverTests.cs
Normal file
173
tests/AcDream.Headless.Tests/HeadlessCredentialResolverTests.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
129
tests/AcDream.Headless.Tests/HeadlessPathSetTests.cs
Normal file
129
tests/AcDream.Headless.Tests/HeadlessPathSetTests.cs
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
218
tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs
Normal file
218
tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue