feat(headless): complete portable single-session host

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

View file

@ -0,0 +1,83 @@
namespace AcDream.Headless.Configuration;
internal sealed record HeadlessCommandLine(
string Command,
string ConfigurationPath,
HeadlessPathOverrides Paths)
{
internal static HeadlessCommandLine Parse(
IReadOnlyList<string> arguments)
{
ArgumentNullException.ThrowIfNull(arguments);
if (arguments.Count < 3)
throw new HeadlessCommandLineException("Missing command options.");
string command = arguments[0];
if (command is not ("validate" or "run"))
throw new HeadlessCommandLineException("Unknown command.");
string? configurationPath = null;
string? configDirectory = null;
string? dataDirectory = null;
string? cacheDirectory = null;
for (int index = 1; index < arguments.Count; index += 2)
{
if (index + 1 >= arguments.Count)
{
throw new HeadlessCommandLineException(
"Every command option requires a value.");
}
string name = arguments[index];
string value = arguments[index + 1];
if (string.IsNullOrWhiteSpace(value))
{
throw new HeadlessCommandLineException(
"Command option values cannot be empty.");
}
switch (name)
{
case "--config":
SetOnce(ref configurationPath, value);
break;
case "--config-dir":
SetOnce(ref configDirectory, value);
break;
case "--data-dir":
SetOnce(ref dataDirectory, value);
break;
case "--cache-dir":
SetOnce(ref cacheDirectory, value);
break;
default:
throw new HeadlessCommandLineException(
"Unknown command option.");
}
}
if (configurationPath is null)
{
throw new HeadlessCommandLineException(
"The --config option is required.");
}
return new HeadlessCommandLine(
command,
configurationPath,
new HeadlessPathOverrides(
configDirectory,
dataDirectory,
cacheDirectory));
}
private static void SetOnce(ref string? destination, string value)
{
if (destination is not null)
{
throw new HeadlessCommandLineException(
"Command options cannot be repeated.");
}
destination = value;
}
}

View file

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

View file

@ -7,12 +7,73 @@ internal sealed class HeadlessConfiguration
[JsonRequired]
public int Version { get; init; }
public HeadlessProcessSettings Process { get; init; } = new();
[JsonRequired]
public List<HeadlessSessionDescriptor?> Sessions { get; init; } = [];
}
internal sealed class HeadlessProcessSettings
{
public HeadlessPathOverrides Paths { get; init; } = new();
}
internal sealed class HeadlessSessionDescriptor
{
[JsonRequired]
public string Id { get; init; } = string.Empty;
[JsonRequired]
public HeadlessEndpointDescriptor Endpoint { get; init; } = new();
[JsonRequired]
public string Account { get; init; } = string.Empty;
[JsonRequired]
public HeadlessCharacterSelector Character { get; init; } = new();
[JsonRequired]
public HeadlessBotPolicyDescriptor Policy { get; init; } = new();
[JsonRequired]
public HeadlessCredentialReference Credential { get; init; } = new();
}
internal sealed class HeadlessEndpointDescriptor
{
[JsonRequired]
public string Host { get; init; } = string.Empty;
[JsonRequired]
public int Port { get; init; }
}
internal sealed class HeadlessCharacterSelector
{
public int? Index { get; init; }
public uint? Id { get; init; }
public string? Name { get; init; }
}
internal sealed class HeadlessBotPolicyDescriptor
{
[JsonRequired]
public string Id { get; init; } = string.Empty;
}
[JsonConverter(typeof(JsonStringEnumConverter<HeadlessCredentialProviderKind>))]
internal enum HeadlessCredentialProviderKind
{
Environment,
StandardInput,
File,
}
internal sealed class HeadlessCredentialReference
{
[JsonRequired]
public HeadlessCredentialProviderKind Provider { get; init; }
[JsonRequired]
public string Reference { get; init; } = string.Empty;
}

View file

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

View file

@ -0,0 +1,13 @@
namespace AcDream.Headless.Configuration;
internal sealed record HeadlessPathOverrides(
string? ConfigDirectory = null,
string? DataDirectory = null,
string? CacheDirectory = null)
{
internal HeadlessPathOverrides Merge(HeadlessPathOverrides commandLine) =>
new(
commandLine.ConfigDirectory ?? ConfigDirectory,
commandLine.DataDirectory ?? DataDirectory,
commandLine.CacheDirectory ?? CacheDirectory);
}