acdream/tests/AcDream.Headless.Tests/HeadlessConfigurationLoaderTests.cs
Erik 09cb548a32 feat(headless): Campaign OP slice OP7 — declared characterOptions with seed-diff sends
Adds an optional, strict `characterOptions` block to the headless bot config
(D8): keys are exactly the lane-B tier-1 (22) + tier-2 (4) bot-declarable
CharacterOptionId enum-member spellings; an unknown/out-of-tier name fails
config load naming the offending key, before it can ever reach the wire.

HeadlessCharacterOptionsSeeder diffs declared-vs-actual once both of ACE's
real preconditions are known true — GameActionLoginComplete sent (the
FirstEnterWorldDone gate SetCharacterOptions 0x01A1 needs) and a real
PlayerDescription has seeded RuntimeCharacterOptionsState
(HasServerSeed) — learned from whichever of two hooks lands second. Every
differing id routes through OP1's shared IRuntimeCharacterCommands seam:
auto-save ids send SetSingleOption (0x0005) immediately; batched ids also
call SetSingleOption (which only dirties the module) followed by exactly
one SaveOptions flush after the whole declared set has been walked.
Idempotent on reconnect by construction — no dedupe latch, the diff simply
finds nothing once the server agrees.

RuntimeLiveEntitySessionController gains a passive onLoginCompleteSent
observation hook (additive only, never changes when/whether it sends) so
the headless host can learn ACE's gate opened from any of its own two
internal send sites; the third site (direct first-entry completion) is
already owned by HeadlessSessionHost itself. All wiring is synchronous
delegate calls on Runtime's one dedicated update thread — no new
async/Task continuation, honoring #368.

Tests: schema (valid parse, unknown/tier-3 name rejected naming the key,
non-bool rejected, empty/absent no-op), the diff engine against a fake
IRuntimeCharacterCommands (nothing-to-send, auto-save-only, batched-with-
flush, mixed ordering, reconnect idempotence), and two wiring integration
tests — one dispatching a real PlayerDescription game event end-to-end to
a captured wire action, one proving the send lands on the same dedicated
thread every Tick runs on. Full solution suite: 12,935 passed / 4 skipped
/ 0 failed (+17 over baseline 12,918/4/0).

No register row: the characterOptions bot-config surface is acdream-
native tooling over retail's own wire mechanisms (both already ported by
OP1), not a retail UI port with a divergence to record.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 02:45:08 +02:00

177 lines
6.2 KiB
C#

using System.Text.Json;
using AcDream.Headless.Configuration;
namespace AcDream.Headless.Tests;
/// <summary>
/// Campaign OP slice OP7 (2026-08-11), D8: schema tests for the optional
/// per-session <c>characterOptions</c> block —
/// docs/plans/2026-08-10-options-panel-campaign.md §4 OP7. Exercises
/// <see cref="HeadlessConfigurationLoader.Load"/> directly (rather than
/// through <c>HeadlessEntryPoint</c>'s CLI wrapper) so assertions can pin the
/// exact exception type/message for the semantic "unknown name" rejection,
/// matching the loader's own established split: type-shape violations
/// (missing required field, wrong JSON value kind) fail during
/// deserialization itself with a raw <see cref="JsonException"/>; semantic
/// violations of an already-well-typed value fail with
/// <see cref="HeadlessConfigurationException"/> (see
/// <see cref="HeadlessConfigurationLoader"/>'s own doc comment on
/// <c>ValidateCharacterOptions</c>).
/// </summary>
public sealed class HeadlessConfigurationLoaderTests
{
[Fact]
public void ValidCharacterOptionsBlockParsesExactDeclaredNames()
{
using TemporaryConfiguration file = TemporaryConfiguration.Create(
ConfigurationWith(Session(
"bot",
"BOT_PASSWORD",
"""
"characterOptions":{
"IgnoreAllegianceRequests":true,
"ListenToTradeChat":false,
"AutoTarget":true
}
""")));
HeadlessConfiguration configuration =
HeadlessConfigurationLoader.Load(file.Path);
Dictionary<string, bool>? declared =
Assert.Single(configuration.Sessions)!.CharacterOptions;
Assert.NotNull(declared);
Assert.Equal(3, declared!.Count);
Assert.True(declared["IgnoreAllegianceRequests"]);
Assert.False(declared["ListenToTradeChat"]);
Assert.True(declared["AutoTarget"]);
}
[Fact]
public void UnknownOptionNameFailsLoadNamingTheOffendingKey()
{
using TemporaryConfiguration file = TemporaryConfiguration.Create(
ConfigurationWith(Session(
"bot",
"BOT_PASSWORD",
"\"characterOptions\":{\"NotARealOption\":true}")));
HeadlessConfigurationException exception = Assert.Throws<
HeadlessConfigurationException>(
() => HeadlessConfigurationLoader.Load(file.Path));
Assert.Contains(
"NotARealOption",
exception.Message,
StringComparison.Ordinal);
}
[Fact]
public void PresentationOnlyTierThreeOptionNameFailsLoad()
{
// ShowHelm is a REAL CharacterOptionId member (0x2F) but is
// deliberately outside the tier-1+2 bot-declarable subset (research
// doc §5.2 tier 3) — excluded by construction, not merely absent
// from an allow-list that forgot it.
using TemporaryConfiguration file = TemporaryConfiguration.Create(
ConfigurationWith(Session(
"bot",
"BOT_PASSWORD",
"\"characterOptions\":{\"ShowHelm\":true}")));
HeadlessConfigurationException exception = Assert.Throws<
HeadlessConfigurationException>(
() => HeadlessConfigurationLoader.Load(file.Path));
Assert.Contains(
"ShowHelm",
exception.Message,
StringComparison.Ordinal);
}
[Fact]
public void NonBoolValueFailsLoad()
{
using TemporaryConfiguration file = TemporaryConfiguration.Create(
ConfigurationWith(Session(
"bot",
"BOT_PASSWORD",
"\"characterOptions\":{\"IgnoreAllegianceRequests\":\"yes\"}")));
Assert.ThrowsAny<Exception>(
() => HeadlessConfigurationLoader.Load(file.Path));
}
[Fact]
public void AbsentCharacterOptionsBlockIsANoOp()
{
using TemporaryConfiguration file = TemporaryConfiguration.Create(
ConfigurationWith(Session("bot", "BOT_PASSWORD")));
HeadlessConfiguration configuration =
HeadlessConfigurationLoader.Load(file.Path);
Assert.Null(Assert.Single(configuration.Sessions)!.CharacterOptions);
}
[Fact]
public void EmptyCharacterOptionsBlockIsANoOp()
{
using TemporaryConfiguration file = TemporaryConfiguration.Create(
ConfigurationWith(Session(
"bot",
"BOT_PASSWORD",
"\"characterOptions\":{}")));
HeadlessConfiguration configuration =
HeadlessConfigurationLoader.Load(file.Path);
Dictionary<string, bool>? declared =
Assert.Single(configuration.Sessions)!.CharacterOptions;
Assert.NotNull(declared);
Assert.Empty(declared!);
}
private static string ConfigurationWith(params string[] sessions) =>
$$"""{"version":1,"sessions":[{{string.Join(",", sessions)}}]}""";
private static string Session(
string id,
string credentialReference,
string? extraTopLevelField = null)
{
string suffix = extraTopLevelField is null
? string.Empty
: $",{extraTopLevelField}";
return $"{{\"id\":\"{id}\",\"endpoint\":{{\"host\":\"127.0.0.1\",\"port\":9000}},"
+ "\"account\":\"account\",\"character\":{\"index\":0},"
+ "\"policy\":{\"id\":\"idle\"},\"credential\":"
+ $"{{\"provider\":\"environment\",\"reference\":\"{credentialReference}\"}}"
+ suffix
+ "}";
}
private sealed class TemporaryConfiguration : IDisposable
{
private TemporaryConfiguration(string path)
{
Path = path;
}
internal string Path { get; }
internal static TemporaryConfiguration Create(string json)
{
string path = System.IO.Path.Combine(
System.IO.Path.GetTempPath(),
$"acdream-headless-op7-{Guid.NewGuid():N}.json");
File.WriteAllText(path, json);
return new TemporaryConfiguration(path);
}
public void Dispose()
{
File.Delete(Path);
}
}
}