using System.Text.Json; using System.Text.Json.Serialization; namespace AcDream.App.Configuration; /// /// Campaign LA slice LA1: loads and validates the --session-config /// document for the graphical host. Same strictness as /// AcDream.Headless.Configuration.HeadlessConfigurationLoader /// (camelCase, , camelCase /// string enums) — see that type's own doc for why the two readers are /// independent DTOs rather than a shared type. /// internal static class SessionConfigurationLoader { private const int CurrentVersion = 1; private static readonly JsonSerializerOptions Options = new() { AllowTrailingCommas = false, PropertyNameCaseInsensitive = false, PropertyNamingPolicy = JsonNamingPolicy.CamelCase, ReadCommentHandling = JsonCommentHandling.Disallow, UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow, Converters = { new JsonStringEnumConverter( JsonNamingPolicy.CamelCase, allowIntegerValues: false), }, }; /// Loads the document and returns the exact one configured /// the graphical host runs — the /// document itself may only ever declare exactly one session. internal static (SessionConfiguration Configuration, SessionDescriptor Session) Load( string path) { ArgumentException.ThrowIfNullOrWhiteSpace(path); string fullPath = Path.GetFullPath(path); using FileStream stream = File.OpenRead(fullPath); SessionConfiguration? configuration = JsonSerializer.Deserialize(stream, Options); if (configuration is null) { throw new SessionConfigurationException( "The configuration document is empty."); } if (configuration.Version != CurrentVersion) { throw new SessionConfigurationException( $"Unsupported configuration version {configuration.Version}; " + $"expected {CurrentVersion}."); } if (configuration.Sessions is null || configuration.Sessions.Count != 1) { throw new SessionConfigurationException( "The graphical host requires exactly one configured session."); } SessionDescriptor session = configuration.Sessions[0] ?? throw new SessionConfigurationException( "The configured session cannot be null."); ValidateContent(configuration.Process?.Content); ValidateSession(session); return (configuration, session); } private static void ValidateContent(SessionContentDescriptor? content) { if (content is null) return; if (string.IsNullOrWhiteSpace(content.DatDirectory) || string.IsNullOrWhiteSpace(content.PreparedAssetPath)) { throw new SessionConfigurationException( "process.content requires non-empty datDirectory and preparedAssetPath."); } } private static void ValidateSession(SessionDescriptor session) { if (string.IsNullOrWhiteSpace(session.Id)) { throw new SessionConfigurationException( "The session requires a non-empty id."); } if (session.Endpoint is null || string.IsNullOrWhiteSpace(session.Endpoint.Host) || session.Endpoint.Port is < 1 or > 65535) { throw new SessionConfigurationException( $"Session '{session.Id}' requires a host and a port from 1 through 65535."); } if (string.IsNullOrWhiteSpace(session.Account)) { throw new SessionConfigurationException( $"Session '{session.Id}' requires a non-empty account."); } if (session.Character is { } selector) { int selectorCount = (selector.Index.HasValue ? 1 : 0) + (selector.Id.HasValue ? 1 : 0) + (!string.IsNullOrWhiteSpace(selector.Name) ? 1 : 0); if (selectorCount != 1 || selector.Index is < 0 || selector.Id == 0u) { throw new SessionConfigurationException( $"Session '{session.Id}' character selector must specify " + "exactly one valid index, id, or name."); } } if (session.Credential is null || string.IsNullOrWhiteSpace(session.Credential.Reference)) { throw new SessionConfigurationException( $"Session '{session.Id}' requires a credential reference."); } if (session.Plugins is { } plugins) { foreach (string? plugin in plugins) { if (string.IsNullOrWhiteSpace(plugin)) { throw new SessionConfigurationException( $"Session '{session.Id}' plugins entries must be non-empty strings."); } } } if (session.LoginCommandDelayMs < 0) { throw new SessionConfigurationException( $"Session '{session.Id}' loginCommandDelayMs must be non-negative."); } if (session.StatusFile is not null && string.IsNullOrWhiteSpace(session.StatusFile)) { throw new SessionConfigurationException( $"Session '{session.Id}' statusFile must be a non-empty path when present."); } ValidateMode(session); } /// /// Campaign LA slice LA1 review fix (F2): mode is Headless-only /// on the App host — the graphical host has no probe concept (LA2 /// builds the probe in Headless only). An absent field is today's ONLY /// App behavior (play); "probe" gets a specific, actionable /// message instead of a cryptic unmapped-member JSON error; anything /// else is a plain configuration error. /// private static void ValidateMode(SessionDescriptor session) { if (session.Mode is null) return; if (string.Equals(session.Mode, "probe", StringComparison.Ordinal)) { throw new SessionConfigurationException( $"Session '{session.Id}' has mode 'probe'; probe sessions " + "are headless-only and cannot run on the graphical host."); } throw new SessionConfigurationException( $"Session '{session.Id}' has unsupported mode '{session.Mode}'."); } }