acdream/src/AcDream.Headless/Configuration/HeadlessConfiguration.cs
Erik af9327a17b
Some checks failed
CI / linux-portable (push) Failing after 3m12s
CI / windows-gate (push) Failing after 6m35s
CI / release (push) Has been skipped
feat(launcher): stabilize prepared content updates
2026-08-25 19:17:13 +02:00

213 lines
8.3 KiB
C#

using System.Text.Json.Serialization;
namespace AcDream.Headless.Configuration;
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();
public HeadlessContentDescriptor? Content { get; init; }
}
internal sealed class HeadlessContentDescriptor
{
[JsonRequired]
public string DatDirectory { get; init; } = string.Empty;
[JsonRequired]
public string PreparedAssetPath { get; init; } = string.Empty;
public string? PreparedAssetOverlayPath { get; init; }
public uint? PreparedAssetBaseRecipeVersion { get; init; }
public uint? PreparedAssetEffectiveRecipeVersion { get; init; }
}
// MF-1 (Campaign OP OP7 review fix, 2026-08-11): record, not class — the
// direct-CLI launch path (HeadlessProcessHost.WithAccount) needs a `with`
// expression so adding a future property can't silently drop it from a
// hand-copied clone the way `CharacterOptions` was dropped here (the K3
// direct-credential launch mode reached HeadlessSessionHost with
// CharacterOptions == null, silently no-op'ing the whole OP7 feature).
internal sealed record HeadlessSessionDescriptor
{
[JsonRequired]
public string Id { get; init; } = string.Empty;
[JsonRequired]
public HeadlessEndpointDescriptor Endpoint { get; init; } = new();
[JsonRequired]
public string Account { get; init; } = string.Empty;
/// <summary>
/// Campaign LA slice LA2: the JSON field is ABSENT for normal play
/// sessions (explicit JSON <c>null</c> is invalid);
/// <see cref="HeadlessSessionMode.Probe"/> for the LA2 probe
/// (connect → characterList → graceful disconnect, never EnterWorld) —
/// the pinned launch-contract schema's <c>mode</c> field
/// (<c>docs/plans/2026-08-14-launcher-campaign.md</c> LA1/LA2).
/// <see cref="Character"/>/<see cref="Policy"/> requiredness depends on
/// this value, which is why their requiredness lives in
/// <see cref="HeadlessConfigurationLoader"/>'s semantic validation rather
/// than a <c>[JsonRequired]</c> attribute — that attribute fires during
/// deserialization, before <see cref="Mode"/> can be inspected at all.
/// </summary>
public HeadlessSessionMode? Mode { get; init; }
/// <summary>
/// Required for play sessions (<see cref="Mode"/> absent); MUST be
/// omitted for probe sessions (<see cref="HeadlessSessionMode.Probe"/>) —
/// the pinned contract keeps the shape unambiguous by forbidding a probe
/// session from also declaring a selector. Enforced by
/// <see cref="HeadlessConfigurationLoader.ValidateSession"/>, not
/// <c>[JsonRequired]</c> (see this record's own doc on <see cref="Mode"/>).
/// </summary>
public HeadlessCharacterSelector? Character { get; init; }
/// <summary>Same mode-dependent requiredness as <see cref="Character"/>:
/// required for play sessions, forbidden for probe sessions.</summary>
public HeadlessBotPolicyDescriptor? Policy { get; init; }
[JsonRequired]
public HeadlessCredentialReference Credential { get; init; } = new();
/// <summary>
/// Campaign OP slice OP7 (2026-08-11), D8: optional declared character-
/// option overrides. Keys MUST be exact <c>CharacterOptionId</c> enum-
/// member spellings drawn from the lane-B tier-1+tier-2 bot-relevant
/// subset (docs/research/2026-08-10-character-options-map.md §5.2/§7.3)
/// — <see cref="HeadlessConfigurationLoader"/> rejects everything else
/// at load, before any name can reach the wire (ACE throws
/// <c>KeyNotFoundException</c> server-side on an unmodelled id — set-
/// character-options-wire.md §5.4.2). Dictionary VALUES bypass the
/// loader's camelCase property-naming policy entirely (only C# property
/// names go through that policy; JSON object keys inside a
/// <c>Dictionary&lt;string, TValue&gt;</c> are read verbatim), so the
/// exact PascalCase enum spelling is what the config file must contain.
/// <c>null</c> (the field entirely absent) and an empty object are both
/// legal no-ops.
/// </summary>
public Dictionary<string, bool>? CharacterOptions { get; init; }
/// <summary>
/// Campaign LA slice LA1: plugin ids to load from the standard plugins
/// directory (<c>docs/plans/2026-08-14-launcher-campaign.md</c> LA1).
/// Absent means load every discovered plugin (the developer flow);
/// explicit empty means load none. LA5 host composition consumes this
/// as the actual allow-list filter.
/// </summary>
public List<string>? Plugins { get; init; }
/// <summary>
/// Campaign LA slice LA1/LA6: ordered chat-typed strings run through the
/// shared Runtime parser/router once the session enters world.
/// </summary>
public List<string>? LoginCommands { get; init; }
/// <summary>
/// Campaign LA slice LA1: inter-command delay for
/// <see cref="LoginCommands"/>, in milliseconds. Matches the pinned
/// launch-contract default (500 ms) when the field is absent from the
/// document.
/// </summary>
public int LoginCommandDelayMs { get; init; } = 500;
/// <summary>
/// Campaign LA slice LA1: absolute path for this session's status-event
/// JSONL stream (<c>docs/superpowers/specs/2026-08-14-launcher-campaign-design.md</c>
/// §6). Absent selects the writer's permanent no-op mode.
/// </summary>
public string? StatusFile { get; init; }
}
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;
/// <summary>
/// Campaign FA slice FA6: optional role discriminator for a policy that
/// coordinates two bots run from the SAME process config (e.g. the
/// fellowship/allegiance gate's Leader — fellowship leader AND
/// allegiance patron — vs Recruit — fellowship recruit AND allegiance
/// vassal, docs/research/2026-08-11-fa-acdream-seams.md §6.2). Ignored
/// by every policy id that doesn't need it (all five pre-FA6 policies);
/// <see cref="AcDream.Headless.Policies.HeadlessBotPolicyFactory.Create"/>
/// rejects a missing role for a policy id that requires one.
/// </summary>
public HeadlessBotPolicyRole? Role { get; init; }
}
/// <summary>
/// Campaign LA slice LA2: see <see cref="HeadlessSessionDescriptor.Mode"/>.
/// The pinned launch-contract schema defines exactly two states for a
/// session — ABSENT (mapped to <see langword="null"/>, meaning "play") or
/// the literal string <c>"probe"</c> — so <see cref="Probe"/> is the only
/// member; there is no explicit "play" spelling. This deliberately uses
/// <see cref="HeadlessConfigurationLoader"/>'s global camel-case,
/// string-only enum converter; a per-enum converter with its default options
/// would accidentally accept numeric <c>0</c> as a second probe spelling.
/// </summary>
internal enum HeadlessSessionMode
{
Probe,
}
/// <summary>See <see cref="HeadlessBotPolicyDescriptor.Role"/>.</summary>
[JsonConverter(typeof(JsonStringEnumConverter<HeadlessBotPolicyRole>))]
internal enum HeadlessBotPolicyRole
{
/// <summary>Fellowship leader / allegiance patron — creates the
/// fellowship, recruits the Recruit bot, and receives its oath.</summary>
Leader,
/// <summary>Fellowship recruit / allegiance vassal — gets recruited and
/// swears to the Leader bot.</summary>
Recruit,
}
[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;
}