acdream/tests/AcDream.Runtime.Tests/Session/SessionStatusWriterTests.cs
Erik 5eaad2c88c feat(net,runtime): Campaign CC CC2 — CharacterCreate wire, 0xF643 correlation, creation status events
Wire (Core.Net):
- CharacterCreate.cs: outbound 0xF656 builder, byte-exact port of
  Proto_UI::SendCharGenResult@0x00546a70 -> ACCharGenResult::Pack@0x005c7570
  -> CG_Pack@0x005c7200. Account String16L first (packed outside CG_Pack),
  then the constant-1 u32, heritage/gender, 14 appearance strip/style/color
  u32s, 6 f64 shades (skin/hair/headgear/shirt/trousers/footwear, retail
  order), template, 6 attributes, slot, classId, numSkills + exactly 55
  u32 skill-advancement classes (ReadOnlySpan validated ==55, throws
  ArgumentException otherwise — ACE terminates the session on any other
  count via PlayerFactory.CreateResult.ClientServerSkillsMismatch), name
  String16L, startArea, isAdmin, isEnvoy, and a trailing checksum whose
  exact 19-term accumulation set (heritage+gender+3 strips+hairColor+
  eyeColor+hairStyle+headgearStyle+shirtStyle+trousersStyle+footwearStyle+
  template+6 attributes) is read byte-for-byte off CG_Pack's decompiled
  accumulator (0x005c7213-0x005c74c3) — headgearColor/shirtColor/
  trousersColor/footwearColor/shades/slot/classId are deliberately absent
  from the sum despite sitting adjacent on the wire. Cross-checked against
  ACE's CharacterCreateInfo.Unpack/Appearance.Unpack and holtburger's
  CharacterCreateRequestData (types.rs:236-369), which agree on every
  field and order. Retail routes via SendToLogon — the same queue
  CharacterDelete already uses.
- CharGenVerificationResponse.cs (new): promotes the shared 0xF643 parse
  out of CharacterRestore — full Code enum (Undef..AdminPrivilegeDenied=7,
  ACE's CharacterGenerationVerificationResponse) plus the conditional
  Ok-only identity payload (guid/String16L name/u32 secondsGreyedOut).
  CharacterRestore.Parse now delegates to it; CharacterRestore's public
  Parsed shape, Parse signature, and every existing test expectation are
  UNCHANGED.
- PacketWriter.WriteDouble: f64 little-endian helper for the shade fields.

WorldSession dispatch (Core.Net):
- Added an awaiting-request latch (None/Restore/Create), armed by
  SendRestoreCharacter/the new SendCharacterCreation immediately before
  each send (SendCharacterCreation builds the body first so a skill-count
  throw never arms the latch for a request that was never sent), cleared
  the instant a matching 0xF643 is dispatched (success OR parse failure —
  a malformed reply must never wedge the latch open) and on Dispose.
  0xF643 now routes to CharacterRestoreReceived or the new
  CharacterCreateResponseReceived (Action<CharGenVerificationResponse.Parsed>)
  by that latch; an unexpected 0xF643 with nothing outstanding logs once
  and is dropped, never misattributed. Fixed
  WorldSessionCharacterSelectionTests' restore-dispatch test, which
  previously fed a bare CharacterRestore response with no preceding
  SendRestoreCharacter — that shape is now the "no outstanding request"
  drop path by design.

Status events (Runtime + Launcher.Core, contract first):
- Amended docs/plans/2026-08-14-launcher-campaign.md §LA1's pinned status
  vocabulary to add characterCreated{guid,name} (Ok reply identity, named
  to mirror CharGenVerificationResponse's own fields and to read distinct
  from enteredWorld — retail logs a freshly created character straight in
  without a fresh characterList) and creationFailed{code,name} (raw Code
  value + its enum member name).
- SessionStatusWriter.CharacterCreated/CreationFailed implement that
  contract.
- Launcher.Core: CharacterCreatedStatusEvent/CreationFailedStatusEvent +
  StatusEventParser cases, in lockstep.

Tests: CharacterCreateTests (byte-exact layout incl. checksum term-set,
55-slot fixture, wrong-count throws), CharGenVerificationResponseTests
(every Code value), WorldSessionCharacterCreationTests (create-then-
response routes correctly, restore unaffected, no-outstanding drop,
second-response-after-consumed drop, Dispose clears the latch, a builder
throw never arms it), SessionStatusWriterTests + Launcher.Core
StatusEventParserTests/StatusFileTailerTests (pinned shape + tailer
round-trip) for the two new events.

Verified: dotnet build AcDream.slnx -c Release — 0 errors. Full solution
test run green (Core.Net.Tests 993/993, Runtime.Tests 1667/1667,
Launcher.Core.Tests 323/323, plus every other project in the solution).
WSL Ubuntu: Core.Net.Tests 993/993, Runtime.Tests 1667/1667.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 12:49:52 +02:00

425 lines
18 KiB
C#

using System.Text.Json;
using AcDream.Runtime.Session;
namespace AcDream.Runtime.Tests.Session;
/// <summary>
/// Campaign LA slice LA1: pins the exact JSONL status-stream contract both
/// the App and Headless hosts write into, and the launcher (a process we
/// don't own) reads — see <c>docs/plans/2026-08-14-launcher-campaign.md</c>
/// LA1 and <c>docs/superpowers/specs/2026-08-14-launcher-campaign-design.md</c>
/// §6.
/// </summary>
public sealed class SessionStatusWriterTests
{
[Fact]
public void EachEventWritesTheExactPinnedShapeInOrder()
{
using TemporaryFile file = TemporaryFile.Create();
var writer = new SessionStatusWriter(file.Path);
writer.Started("s1");
writer.Connected("s1");
writer.CharacterList(
"s1",
new LiveSessionRosterReport(
"account",
11,
[
new LiveSessionRosterEntry(0x50000001u, "Ready", 0u),
new LiveSessionRosterEntry(0x50000002u, "Grey", 10u),
]));
writer.EnteredWorld("s1", 0x50000001u, "Ready");
writer.PluginLoaded("s1", "acdream.good");
writer.PluginFailed("s1", "acdream.bad", "enable failed");
writer.LoginCommandFailed("s1", 2, "/version", "unsupported headless command");
writer.Disconnected("s1", "stopped");
writer.Exited("s1", 0, "disposed");
string[] lines = File.ReadAllLines(file.Path);
Assert.Equal(9, lines.Length);
JsonElement started = Parse(lines[0]);
Assert.Equal(1, started.GetProperty("v").GetInt32());
Assert.Equal("started", started.GetProperty("e").GetString());
Assert.True(started.TryGetProperty("t", out _));
Assert.Equal("s1", started.GetProperty("sessionId").GetString());
JsonElement connected = Parse(lines[1]);
Assert.Equal("connected", connected.GetProperty("e").GetString());
Assert.Equal("s1", connected.GetProperty("sessionId").GetString());
JsonElement characterList = Parse(lines[2]);
Assert.Equal("characterList", characterList.GetProperty("e").GetString());
Assert.Equal("account", characterList.GetProperty("accountName").GetString());
Assert.Equal(11, characterList.GetProperty("slotCount").GetInt32());
JsonElement characters = characterList.GetProperty("characters");
Assert.Equal(2, characters.GetArrayLength());
JsonElement first = characters[0];
Assert.Equal(0x50000001u, first.GetProperty("id").GetUInt32());
Assert.Equal("Ready", first.GetProperty("name").GetString());
Assert.Equal(0u, first.GetProperty("secondsGreyedOut").GetUInt32());
JsonElement enteredWorld = Parse(lines[3]);
Assert.Equal("enteredWorld", enteredWorld.GetProperty("e").GetString());
Assert.Equal(0x50000001u, enteredWorld.GetProperty("characterId").GetUInt32());
Assert.Equal("Ready", enteredWorld.GetProperty("characterName").GetString());
JsonElement pluginLoaded = Parse(lines[4]);
Assert.Equal("pluginLoaded", pluginLoaded.GetProperty("e").GetString());
Assert.Equal("acdream.good", pluginLoaded.GetProperty("plugin").GetString());
JsonElement pluginFailed = Parse(lines[5]);
Assert.Equal("pluginFailed", pluginFailed.GetProperty("e").GetString());
Assert.Equal("acdream.bad", pluginFailed.GetProperty("plugin").GetString());
Assert.Equal("enable failed", pluginFailed.GetProperty("error").GetString());
JsonElement loginCommandFailed = Parse(lines[6]);
Assert.Equal(1, loginCommandFailed.GetProperty("v").GetInt32());
Assert.Equal("loginCommandFailed", loginCommandFailed.GetProperty("e").GetString());
Assert.Equal("s1", loginCommandFailed.GetProperty("sessionId").GetString());
Assert.Equal(2, loginCommandFailed.GetProperty("commandIndex").GetInt32());
Assert.Equal("/version", loginCommandFailed.GetProperty("command").GetString());
Assert.Equal(
"unsupported headless command",
loginCommandFailed.GetProperty("error").GetString());
Assert.Equal(
["v", "e", "t", "sessionId", "commandIndex", "command", "error"],
loginCommandFailed.EnumerateObject()
.Select(static property => property.Name));
JsonElement disconnected = Parse(lines[7]);
Assert.Equal("disconnected", disconnected.GetProperty("e").GetString());
Assert.Equal("stopped", disconnected.GetProperty("reason").GetString());
JsonElement exited = Parse(lines[8]);
Assert.Equal("exited", exited.GetProperty("e").GetString());
Assert.Equal(0, exited.GetProperty("code").GetInt32());
Assert.Equal("disposed", exited.GetProperty("reason").GetString());
}
[Fact]
public void NoOpWriterNeverCreatesAFile()
{
using TemporaryFile file = TemporaryFile.Reserve();
var writer = new SessionStatusWriter(null);
writer.Started("s1");
writer.Connected("s1");
writer.PluginLoaded("s1", "acdream.good");
writer.PluginFailed("s1", "acdream.bad", "failed");
writer.LoginCommandFailed("s1", 0, "", "unknown command");
writer.CharacterCreated("s1", 0x50000001u, "NewChar");
writer.CreationFailed("s1", 3u, "NameInUse");
writer.Disconnected("s1", "stopped");
writer.Exited("s1", 0, "disposed");
Assert.False(writer.IsEnabled);
Assert.False(File.Exists(file.Path));
}
/// <summary>
/// Campaign CC CC2: pins the exact shape of the two new creation-flow
/// status events, added to the LA1 vocabulary alongside
/// <c>CharacterCreate</c> (opcode 0xF656) — see
/// <c>docs/plans/2026-08-14-launcher-campaign.md</c> §LA1's amended
/// status-vocabulary text.
/// </summary>
[Fact]
public void CharacterCreatedAndCreationFailed_WriteThePinnedShape()
{
using TemporaryFile file = TemporaryFile.Create();
var writer = new SessionStatusWriter(file.Path);
writer.CharacterCreated("s1", 0x50000010u, "NewChar");
writer.CreationFailed("s1", 3u, "NameInUse");
string[] lines = File.ReadAllLines(file.Path);
Assert.Equal(2, lines.Length);
JsonElement created = Parse(lines[0]);
Assert.Equal(1, created.GetProperty("v").GetInt32());
Assert.Equal("characterCreated", created.GetProperty("e").GetString());
Assert.Equal("s1", created.GetProperty("sessionId").GetString());
Assert.Equal(0x50000010u, created.GetProperty("guid").GetUInt32());
Assert.Equal("NewChar", created.GetProperty("name").GetString());
AssertExactProperties(lines[0], "v", "e", "t", "sessionId", "guid", "name");
JsonElement failed = Parse(lines[1]);
Assert.Equal("creationFailed", failed.GetProperty("e").GetString());
Assert.Equal("s1", failed.GetProperty("sessionId").GetString());
Assert.Equal(3u, failed.GetProperty("code").GetUInt32());
Assert.Equal("NameInUse", failed.GetProperty("name").GetString());
AssertExactProperties(lines[1], "v", "e", "t", "sessionId", "code", "name");
}
[Fact]
public void BlankPathIsTreatedAsAbsent()
{
var writer = new SessionStatusWriter(" ");
Assert.False(writer.IsEnabled);
// Must not throw even though there is no real path behind it.
writer.Started("s1");
}
/// <summary>
/// F3: both hosts share this writer but have separate reconnect command
/// adapters. The sink therefore closes an open connection before it
/// accepts another connected edge, preserving a coherent external
/// lifecycle even if a host has no reconnect-specific status hook.
/// </summary>
[Fact]
public void SecondConnectedEdgeFirstClosesTheRetiringConnection()
{
using TemporaryFile file = TemporaryFile.Create();
var writer = new SessionStatusWriter(file.Path);
writer.Connected("s1");
writer.Connected("s1");
JsonElement[] events = File.ReadAllLines(file.Path)
.Select(Parse)
.ToArray();
Assert.Equal(
["connected", "disconnected", "connected"],
events.Select(static item => item.GetProperty("e").GetString()));
Assert.Equal(
"reconnect",
events[1].GetProperty("reason").GetString());
}
/// <summary>
/// F6: exited is a terminal fact, not an append request. Repeated host
/// disposal and any late callback after disposal must not create a second
/// terminal edge or resurrect the stream. If a host exits while still
/// connected, the writer closes that connection first with a distinct,
/// truthful reason.
/// </summary>
[Fact]
public void ExitedIsIdempotentTerminalAndClosesAnOpenConnection()
{
using TemporaryFile file = TemporaryFile.Create();
var writer = new SessionStatusWriter(file.Path);
writer.Connected("s1");
writer.Exited("s1", 0, "graceful");
writer.Exited("s1", 1, "duplicate-must-not-win");
writer.Connected("s1");
JsonElement[] events = File.ReadAllLines(file.Path)
.Select(Parse)
.ToArray();
Assert.Equal(
["connected", "disconnected", "exited"],
events.Select(static item => item.GetProperty("e").GetString()));
Assert.Equal(
"process-exit",
events[1].GetProperty("reason").GetString());
Assert.Equal(0, events[2].GetProperty("code").GetInt32());
Assert.Equal("graceful", events[2].GetProperty("reason").GetString());
}
/// <summary>
/// F7 (Campaign LA LA1 review fix round): replaces the earlier
/// "DoesNotContain 'hunter2'/'password'" assertion, which could never
/// actually fail — no writer method below accepts a credential-shaped
/// parameter in the first place, so the absence of those literal strings
/// proved nothing about the SHAPE of what gets serialized. This test
/// asserts the structural claim that actually backs the "never write
/// credential material into this stream" contract: each event kind
/// serializes EXACTLY its pinned property set — the shared envelope
/// (<c>v</c>/<c>e</c>/<c>t</c>/<c>sessionId</c>) plus that event's own
/// named fields, nothing else. An extra credential-shaped or otherwise
/// accidental property fails this test by construction. LA5's documented
/// <c>pluginFailed.error</c> diagnostic is the one free-text value and its
/// caller remains responsible for never appending session secrets.
/// </summary>
[Fact]
public void EachEventSerializesExactlyItsPinnedPropertySetAndNothingElse()
{
using TemporaryFile file = TemporaryFile.Create();
var writer = new SessionStatusWriter(file.Path);
writer.Started("bot");
writer.Connected("bot");
writer.CharacterList(
"bot",
new LiveSessionRosterReport(
"account-name",
11,
[new LiveSessionRosterEntry(0x50000001u, "Ready", 0u)]));
writer.EnteredWorld("bot", 0x50000001u, "Ready");
writer.PluginLoaded("bot", "acdream.good");
writer.PluginFailed("bot", "acdream.bad", "enable failed");
writer.LoginCommandFailed("bot", 1, "/version", "unsupported");
writer.Disconnected("bot", "stopped");
writer.Exited("bot", 0, "disposed");
string[] lines = File.ReadAllLines(file.Path);
Assert.Equal(9, lines.Length);
AssertExactProperties(lines[0], "v", "e", "t", "sessionId");
AssertExactProperties(lines[1], "v", "e", "t", "sessionId");
AssertExactProperties(
lines[2],
"v", "e", "t", "sessionId", "accountName", "slotCount", "characters");
AssertExactProperties(
lines[3], "v", "e", "t", "sessionId", "characterId", "characterName");
AssertExactProperties(lines[4], "v", "e", "t", "sessionId", "plugin");
AssertExactProperties(
lines[5], "v", "e", "t", "sessionId", "plugin", "error");
AssertExactProperties(
lines[6],
"v", "e", "t", "sessionId", "commandIndex", "command", "error");
AssertExactProperties(lines[7], "v", "e", "t", "sessionId", "reason");
AssertExactProperties(lines[8], "v", "e", "t", "sessionId", "code", "reason");
// The nested characters[] entries are exact too — the exact shape a
// password could otherwise be smuggled through.
JsonElement character = Parse(lines[2]).GetProperty("characters")[0];
AssertExactProperties(character, "id", "name", "secondsGreyedOut");
}
private static void AssertExactProperties(string line, params string[] expected) =>
AssertExactProperties(Parse(line), expected);
private static void AssertExactProperties(JsonElement element, params string[] expected)
{
string[] actual = element.EnumerateObject()
.Select(static property => property.Name)
.OrderBy(static name => name, StringComparer.Ordinal)
.ToArray();
string[] sortedExpected = expected
.OrderBy(static name => name, StringComparer.Ordinal)
.ToArray();
Assert.Equal(sortedExpected, actual);
}
/// <summary>
/// F1 (Campaign LA LA1 review fix round): a status file whose parent
/// directory does not exist yet — the expected first-run shape of
/// <c>.../launcher/sessions/&lt;id&gt;/status.jsonl</c> on a fresh cache
/// dir — must be created lazily rather than throwing
/// <see cref="DirectoryNotFoundException"/> out of the transaction the
/// writer is merely observing.
/// </summary>
[Fact]
public void MissingParentDirectoryIsCreatedAndEventsFlow()
{
string root = Path.Combine(
Path.GetTempPath(),
$"acdream-status-root-{Guid.NewGuid():N}");
string path = Path.Combine(root, "nested", "sessions", "s1", "status.jsonl");
try
{
Assert.False(Directory.Exists(Path.GetDirectoryName(path)));
var writer = new SessionStatusWriter(path);
writer.Started("s1");
writer.Connected("s1");
Assert.True(writer.IsEnabled);
string[] lines = File.ReadAllLines(path);
Assert.Equal(2, lines.Length);
Assert.Contains("\"started\"", lines[0]);
Assert.Contains("\"connected\"", lines[1]);
}
finally
{
if (Directory.Exists(root))
Directory.Delete(root, recursive: true);
}
}
/// <summary>
/// F1: a path whose PARENT SEGMENT already exists as an ordinary file
/// (so <see cref="Directory.CreateDirectory"/> cannot turn it into a
/// directory) is exactly the "unwritable path" case the review asked
/// for — the writer must latch itself off instead of throwing, and every
/// subsequent call must stay a cheap no-op.
/// </summary>
[Fact]
public void ParentSegmentIsAFileLatchesTheWriterInsteadOfThrowing()
{
string blocker = Path.Combine(
Path.GetTempPath(),
$"acdream-status-blocker-{Guid.NewGuid():N}");
File.WriteAllText(blocker, "not a directory");
string path = Path.Combine(blocker, "status.jsonl");
try
{
var writer = new SessionStatusWriter(path);
Assert.True(writer.IsEnabled);
// Must not throw — the writer swallows its own I/O failure and
// latches off instead of failing the caller's transaction.
writer.Started("s1");
Assert.False(writer.IsEnabled);
// Latched-off calls stay cheap no-ops — no exception, no retry.
writer.Connected("s1");
writer.Exited("s1", 0, "disposed");
}
finally
{
if (File.Exists(blocker))
File.Delete(blocker);
}
}
[Fact]
public void FileIsOpenedShareReadSoAConcurrentTailerCanReadWhileAppending()
{
using TemporaryFile file = TemporaryFile.Create();
var writer = new SessionStatusWriter(file.Path);
writer.Started("s1");
// A concurrent reader (the launcher's tailer) must be able to open
// the file for read while the writer holds it — FileShare.Read on
// the writer side is what this test is pinning.
using FileStream tailer = new(
file.Path,
FileMode.Open,
FileAccess.Read,
FileShare.ReadWrite);
using var tailerReader = new StreamReader(tailer);
string? firstLine = tailerReader.ReadLine();
Assert.NotNull(firstLine);
Assert.Contains("\"started\"", firstLine);
// The writer keeps working while the tailer's handle is still open.
writer.Connected("s1");
string? secondLine = tailerReader.ReadLine();
Assert.NotNull(secondLine);
Assert.Contains("\"connected\"", secondLine);
}
private static JsonElement Parse(string line) =>
JsonDocument.Parse(line).RootElement;
private sealed class TemporaryFile : IDisposable
{
private TemporaryFile(string path) => Path = path;
internal string Path { get; }
internal static TemporaryFile Create()
{
string path = System.IO.Path.Combine(
System.IO.Path.GetTempPath(),
$"acdream-status-{Guid.NewGuid():N}.jsonl");
return new TemporaryFile(path);
}
/// <summary>A path that is never actually created — used by the
/// no-op test to assert the writer truly never touches disk.</summary>
internal static TemporaryFile Reserve() => Create();
public void Dispose()
{
if (File.Exists(Path))
File.Delete(Path);
}
}
}