fix(launcher): Campaign LA close LA2 review findings

This commit is contained in:
Erik 2026-08-14 17:20:02 +02:00
parent 000ea979d5
commit 1c5e66c05b
8 changed files with 346 additions and 58 deletions

View file

@ -47,8 +47,9 @@ internal sealed record HeadlessSessionDescriptor
public string Account { get; init; } = string.Empty;
/// <summary>
/// Campaign LA slice LA2: ABSENT (<see langword="null"/>) for normal play
/// sessions; <see cref="HeadlessSessionMode.Probe"/> for the LA2 probe
/// 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).

View file

@ -97,10 +97,15 @@ internal static class HeadlessConfigurationLoader
string fullPath = Path.GetFullPath(path);
using FileStream stream = File.OpenRead(fullPath);
using JsonDocument document = JsonDocument.Parse(
stream,
new JsonDocumentOptions
{
AllowTrailingCommas = false,
CommentHandling = JsonCommentHandling.Disallow,
});
HeadlessConfiguration? configuration =
JsonSerializer.Deserialize<HeadlessConfiguration>(
stream,
Options);
document.RootElement.Deserialize<HeadlessConfiguration>(Options);
if (configuration is null)
{
@ -123,9 +128,12 @@ internal static class HeadlessConfigurationLoader
ValidateContent(configuration.Process?.Content);
JsonElement sessionsElement =
document.RootElement.GetProperty("sessions");
var sessionIds = new HashSet<string>(StringComparer.Ordinal);
var credentialReferences = new HashSet<string>(
StringComparer.Ordinal);
int sessionIndex = 0;
foreach (HeadlessSessionDescriptor? session in configuration.Sessions)
{
if (session is null
@ -141,7 +149,7 @@ internal static class HeadlessConfigurationLoader
$"Duplicate session id '{session.Id}'.");
}
ValidateSession(session);
ValidateSession(session, sessionsElement[sessionIndex]);
string credentialKey =
$"{session.Credential.Provider}:{session.Credential.Reference}";
if (!credentialReferences.Add(credentialKey))
@ -149,6 +157,7 @@ internal static class HeadlessConfigurationLoader
throw new HeadlessConfigurationException(
$"Credential reference for session '{session.Id}' is already in use.");
}
sessionIndex++;
}
return configuration;
@ -166,7 +175,9 @@ internal static class HeadlessConfigurationLoader
}
}
private static void ValidateSession(HeadlessSessionDescriptor session)
private static void ValidateSession(
HeadlessSessionDescriptor session,
JsonElement sessionElement)
{
if (session.Endpoint is null
|| string.IsNullOrWhiteSpace(session.Endpoint.Host)
@ -182,7 +193,7 @@ internal static class HeadlessConfigurationLoader
$"Session '{session.Id}' requires a non-empty account.");
}
ValidateModeShape(session);
ValidateModeShape(session, sessionElement);
if (session.Credential is null
|| string.IsNullOrWhiteSpace(session.Credential.Reference))
@ -214,17 +225,37 @@ internal static class HeadlessConfigurationLoader
/// that also declares a selector or a policy, rather than silently
/// ignoring them.
/// </summary>
private static void ValidateModeShape(HeadlessSessionDescriptor session)
private static void ValidateModeShape(
HeadlessSessionDescriptor session,
JsonElement sessionElement)
{
bool hasMode = sessionElement.TryGetProperty(
"mode",
out JsonElement modeElement);
bool hasCharacter = sessionElement.TryGetProperty(
"character",
out JsonElement characterElement);
bool hasPolicy = sessionElement.TryGetProperty(
"policy",
out JsonElement policyElement);
RejectExplicitNull(session.Id, "mode", hasMode, modeElement);
RejectExplicitNull(
session.Id,
"character",
hasCharacter,
characterElement);
RejectExplicitNull(session.Id, "policy", hasPolicy, policyElement);
if (session.Mode == HeadlessSessionMode.Probe)
{
if (session.Character is not null)
if (hasCharacter)
{
throw new HeadlessConfigurationException(
$"Session '{session.Id}' has mode \"probe\" and must omit "
+ "'character' — a probe never selects a character.");
}
if (session.Policy is not null)
if (hasPolicy)
{
throw new HeadlessConfigurationException(
$"Session '{session.Id}' has mode \"probe\" and must omit "
@ -233,6 +264,12 @@ internal static class HeadlessConfigurationLoader
return;
}
if (hasMode)
{
throw new HeadlessConfigurationException(
$"Session '{session.Id}' is normal play and must omit 'mode'.");
}
if (session.Character is null)
{
throw new HeadlessConfigurationException(
@ -259,6 +296,29 @@ internal static class HeadlessConfigurationLoader
}
}
/// <summary>
/// Campaign LA LA2 review fix: the pinned launch contract distinguishes
/// an omitted conditional field from a field explicitly authored as JSON
/// <c>null</c>. Nullable CLR properties cannot retain that distinction, so
/// validation also consumes the already-parsed strict JSON shape. The
/// typed serializer still owns unknown-member, enum, and value-type
/// enforcement; this check adds presence semantics without weakening any
/// of those gates.
/// </summary>
private static void RejectExplicitNull(
string sessionId,
string propertyName,
bool isPresent,
JsonElement value)
{
if (isPresent && value.ValueKind == JsonValueKind.Null)
{
throw new HeadlessConfigurationException(
$"Session '{sessionId}' field '{propertyName}' cannot be null; "
+ "supply a value when allowed or omit the field.");
}
}
/// <summary>
/// Campaign LA slice LA1: validates the four new optional per-session
/// fields shared with the App session-config reader (see