fix(headless,runtime): OP7 review fixes + docs: OP3 re-review REOPEN (narrow)
TWO work products share this commit (a staged-index collision between the
coordinator's docs commit and the OP7 fixer's staged files — content
verified complete and coherent; only this message was wrong before the
amend):
1. OP7 review fixes (all nine findings from
docs/research/2026-08-11-op7-review.md):
- M1: HeadlessSessionDescriptor is a record; WithAccount uses 'with' non-destructive record copy,
so a future property cannot be silently dropped; direct-CLI
regression test proves CharacterOptions survives --user/--password.
- M2 root fix: LiveSessionEventRouter skips BOTH Replace and the
options notification on a trailer-truncated PlayerDescription — a
truncated re-seed can no longer install zeroed words under an armed
latch for OP7's automation to flush into 0x01A1.
- SF1: schema keys validate as ordinal strings against the allowed
names (numeric / comma-combined aliases rejected). SF2: both-true
fellowship exclusion rejected at load, naming both keys. SF3: the
onLoginCompleteSent observer moved after transit.EndTeleport().
SF4: production-hook coverage for all three LoginComplete sites.
SF5: test-script OP7 wire expectation corrected (batched ids ride
only the 0x01A1).
2. docs/research/2026-08-11-op3-rereview.md — OP3 re-review verdict
REOPEN (narrow): M1 byte-decode independently re-verified (6a 07 at
all six sites); residuals R1 (gate script promises a timestamp prefix
acdream doesn't render), R2 (null-controller player-mode still
refuses), R3 (dormancy pin lacks stimulus) — coordinator fixes follow.
Full Release suite at this tree: 12,956 passed / 4 skipped / 0 failed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
386076af0f
commit
7b60e71b85
11 changed files with 1007 additions and 23 deletions
|
|
@ -133,6 +133,140 @@ public sealed class HeadlessSessionHostTests
|
|||
diagnostics.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DirectCredentialsPreserveDeclaredCharacterOptions()
|
||||
{
|
||||
// MF-1 (Campaign OP OP7 review fix, 2026-08-11): the K3 direct-CLI
|
||||
// launch mode (--user/--password) rebuilds the configured session
|
||||
// descriptor via HeadlessProcessHost.WithAccount before constructing
|
||||
// HeadlessSessionHost. A hand-copied clone that forgets a property
|
||||
// silently drops that feature for this launch mode with no error —
|
||||
// exactly what happened to CharacterOptions. Assert the declared
|
||||
// block survives the direct-credential path by checking the
|
||||
// constructed session's seeder actually saw it.
|
||||
var configuration = new HeadlessConfiguration
|
||||
{
|
||||
Version = 1,
|
||||
Sessions =
|
||||
[
|
||||
Descriptor(characterOptions: new Dictionary<string, bool>
|
||||
{
|
||||
["AutoRepeatAttack"] = true,
|
||||
}),
|
||||
],
|
||||
};
|
||||
HeadlessPathSet paths = HeadlessPathSet.Resolve(
|
||||
new HeadlessPathOverrides());
|
||||
using var diagnostics = new StringWriter();
|
||||
var operations = new FixtureSessionOperations();
|
||||
using var host = new HeadlessProcessHost(
|
||||
configuration,
|
||||
paths,
|
||||
TextReader.Null,
|
||||
diagnostics,
|
||||
operations,
|
||||
directCredentials: new HeadlessDirectCredentials(
|
||||
"direct-account",
|
||||
"direct-secret"));
|
||||
using var cancellation = new CancellationTokenSource();
|
||||
cancellation.Cancel();
|
||||
|
||||
HeadlessExitCode result =
|
||||
await host.RunAsync(cancellation.Token);
|
||||
|
||||
Assert.Equal(HeadlessExitCode.Success, result);
|
||||
Assert.Equal("direct-account", operations.LastUser);
|
||||
Assert.NotNull(host.Session.OptionsSeeder);
|
||||
Assert.True(host.Session.OptionsSeeder!.HasDeclaredOptions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DirectFirstEntryCompletion_InvokesOnLoginCompleteSentHookThroughProductionWiring()
|
||||
{
|
||||
// SF-4 (Campaign OP OP7 review fix, 2026-08-11): the THIRD
|
||||
// production LoginComplete->seeder hook lives entirely inside
|
||||
// HeadlessSessionHost.CreateEventRoute's own closure (the direct,
|
||||
// non-portal first-entry completion callback wired to
|
||||
// RuntimeFirstEntryDriveController via HeadlessSessionEventRoute's
|
||||
// localPlayerCompleted parameter) — driving it end-to-end would
|
||||
// need a real DAT-backed collision fixture this test project does
|
||||
// not have. Reflection reaches the SAME closure instance
|
||||
// HeadlessSessionHost actually constructed during Start() (not a
|
||||
// hand-rolled reconstruction of it) and invokes it exactly as
|
||||
// RuntimeFirstEntryDriveController would on residence completion,
|
||||
// then proves the seeder reacted through the real wire — the same
|
||||
// "declared option actually sends" proof
|
||||
// HeadlessCharacterOptionsSeederWiringTests uses for the other two
|
||||
// sites.
|
||||
var operations = new FixtureSessionOperations();
|
||||
using var credential = new HeadlessCredentialSecret(
|
||||
"fixture",
|
||||
"password");
|
||||
using var host = new HeadlessSessionHost(
|
||||
Descriptor(characterOptions: new Dictionary<string, bool>
|
||||
{
|
||||
["IgnoreAllegianceRequests"] = true,
|
||||
}),
|
||||
credential,
|
||||
new HeadlessDiagnosticWriter(TextWriter.Null),
|
||||
operations);
|
||||
Assert.Equal(
|
||||
RuntimeSessionStartStatus.Connected,
|
||||
host.Start().Status);
|
||||
Assert.NotNull(host.OptionsSeeder);
|
||||
|
||||
WorldSession session = operations.Sessions[^1];
|
||||
var sent = new List<byte[]>();
|
||||
session.GameActionCapture = body => sent.Add(body);
|
||||
|
||||
object eventRoute = typeof(HeadlessSessionHost)
|
||||
.GetField(
|
||||
"_eventRoute",
|
||||
BindingFlags.NonPublic | BindingFlags.Instance)!
|
||||
.GetValue(host)
|
||||
?? throw new InvalidOperationException(
|
||||
"HeadlessSessionHost constructed no event route.");
|
||||
var localPlayerCompleted =
|
||||
(Action<RuntimeEntityRecord>?)typeof(HeadlessSessionEventRoute)
|
||||
.GetField(
|
||||
"_localPlayerCompleted",
|
||||
BindingFlags.NonPublic | BindingFlags.Instance)!
|
||||
.GetValue(eventRoute);
|
||||
Assert.NotNull(localPlayerCompleted);
|
||||
|
||||
const uint playerGuid = 0x50000009u;
|
||||
host.Runtime.PlayerIdentity.ServerGuid = playerGuid;
|
||||
RuntimeEntityRecord record = host.Runtime.EntityObjects
|
||||
.RegisterEntity(Spawn(playerGuid))
|
||||
.Canonical!;
|
||||
|
||||
// Invoke the PRODUCTION closure directly — proves
|
||||
// HeadlessSessionHost really wires _optionsSeeder?.NoteLoginCompleteSent()
|
||||
// into this callback, not merely that some test double does.
|
||||
localPlayerCompleted!(record);
|
||||
|
||||
Assert.Contains(
|
||||
sent,
|
||||
body => body.SequenceEqual(GameActionLoginComplete.Build()));
|
||||
|
||||
// No PlayerDescription has landed yet — HasServerSeed is still
|
||||
// false, so nothing beyond LoginComplete can have sent.
|
||||
Assert.DoesNotContain(
|
||||
sent,
|
||||
body => ActionOpcode(body)
|
||||
== SocialActions.SetSingleCharacterOptionOpcode);
|
||||
|
||||
session.GameEvents.Dispatch(
|
||||
GameEventEnvelope.TryParse(
|
||||
WrapPlayerDescriptionEnvelope(options1: 0u, options2: 0u))!
|
||||
.Value);
|
||||
|
||||
Assert.Contains(
|
||||
sent,
|
||||
body => ActionOpcode(body)
|
||||
== SocialActions.SetSingleCharacterOptionOpcode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DirectFrameUsesSharedRetailOrderAndMovementCadence()
|
||||
{
|
||||
|
|
@ -1832,7 +1966,8 @@ public sealed class HeadlessSessionHostTests
|
|||
private static HeadlessSessionDescriptor Descriptor(
|
||||
HeadlessCredentialProviderKind provider =
|
||||
HeadlessCredentialProviderKind.Environment,
|
||||
string credentialReference = "BOT_PASSWORD") => new()
|
||||
string credentialReference = "BOT_PASSWORD",
|
||||
Dictionary<string, bool>? characterOptions = null) => new()
|
||||
{
|
||||
Id = "bot",
|
||||
Endpoint = new HeadlessEndpointDescriptor
|
||||
|
|
@ -1854,6 +1989,7 @@ public sealed class HeadlessSessionHostTests
|
|||
Provider = provider,
|
||||
Reference = credentialReference,
|
||||
},
|
||||
CharacterOptions = characterOptions,
|
||||
};
|
||||
|
||||
private static void HydrateGroundedPlayer(GameRuntime runtime)
|
||||
|
|
@ -2458,6 +2594,43 @@ public sealed class HeadlessSessionHostTests
|
|||
BinaryPrimitives.ReadUInt32LittleEndian(
|
||||
body.AsSpan(8, sizeof(uint)));
|
||||
|
||||
// SF-4 fixture: minimal PlayerDescription (0x0013) body carrying only
|
||||
// the CharacterOptions1/2 trailer fields — copied from
|
||||
// HeadlessCharacterOptionsSeederWiringTests.WrapPlayerDescriptionEnvelope
|
||||
// (mirrors GameEventWiringTests.WireAll_PlayerDescription_PublishesCharacterOptions's
|
||||
// fixture layout).
|
||||
private static byte[] WrapPlayerDescriptionEnvelope(
|
||||
uint options1,
|
||||
uint options2)
|
||||
{
|
||||
var stream = new MemoryStream();
|
||||
using (var writer = new BinaryWriter(
|
||||
stream, System.Text.Encoding.UTF8, leaveOpen: true))
|
||||
{
|
||||
writer.Write(0u); // property flags
|
||||
writer.Write(0x52u); // player weenie type
|
||||
writer.Write(0u); // vector flags
|
||||
writer.Write(0u); // has health
|
||||
writer.Write(0x40u); // option flags: CharacterOptions2
|
||||
writer.Write(options1);
|
||||
writer.Write(0u); // legacy hotbar count
|
||||
writer.Write(0u); // spellbook filters
|
||||
writer.Write(options2);
|
||||
writer.Write(0u); // inventory count
|
||||
writer.Write(0u); // equipped count
|
||||
}
|
||||
|
||||
byte[] payload = stream.ToArray();
|
||||
byte[] body = new byte[GameEventEnvelope.HeaderSize + payload.Length];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body, GameEventEnvelope.Opcode);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), 0u);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), 0u);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(
|
||||
body.AsSpan(12), (uint)GameEventType.PlayerDescription);
|
||||
Array.Copy(payload, 0, body, GameEventEnvelope.HeaderSize, payload.Length);
|
||||
return body;
|
||||
}
|
||||
|
||||
private sealed class FixtureSessionOperations : ILiveSessionOperations
|
||||
{
|
||||
public List<WorldSession> Sessions { get; } = [];
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue