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
|
|
@ -29,7 +29,13 @@ internal sealed class HeadlessContentDescriptor
|
|||
public string PreparedAssetPath { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
internal sealed class HeadlessSessionDescriptor
|
||||
// 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;
|
||||
|
|
|
|||
|
|
@ -56,6 +56,26 @@ internal static class HeadlessConfigurationLoader
|
|||
CharacterOptionId.SalvageMultiple,
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// SF-1 (Campaign OP OP7 review fix, 2026-08-11): the exact allow-listed
|
||||
/// SPELLINGS, checked ordinally against the raw JSON key. Validating
|
||||
/// against <c>Enum.TryParse</c>'s parsed VALUE instead let a key that is
|
||||
/// not an allow-listed spelling at all reach the wire under a different
|
||||
/// id — <c>Enum.TryParse</c> on a non-<c>[Flags]</c> enum still accepts a
|
||||
/// decimal numeric string (<c>"15"</c> parses to <c>0x0F</c>) and a
|
||||
/// comma-separated list of member names, OR-combined
|
||||
/// (<c>"ToggleRun,AutoTarget"</c> = <c>0x0A | 0x0D</c> = <c>0x0F</c>) —
|
||||
/// both alias into <see cref="CharacterOptionId.FellowshipShareXP"/>,
|
||||
/// which is itself allow-listed, so the wrong id silently passed. Keying
|
||||
/// off <c>id.ToString()</c> keeps this set in exact lockstep with
|
||||
/// <see cref="AllowedCharacterOptions"/> with no separate literal list to
|
||||
/// drift out of sync.
|
||||
/// </summary>
|
||||
private static readonly HashSet<string> AllowedCharacterOptionNames =
|
||||
new(
|
||||
AllowedCharacterOptions.Select(static id => id.ToString()),
|
||||
StringComparer.Ordinal);
|
||||
|
||||
private static readonly JsonSerializerOptions Options = new()
|
||||
{
|
||||
AllowTrailingCommas = false,
|
||||
|
|
@ -207,6 +227,11 @@ internal static class HeadlessConfigurationLoader
|
|||
/// every other type-shape violation this loader lets the deserializer
|
||||
/// reject directly — <see cref="HeadlessConfigurationException"/> is
|
||||
/// reserved for semantic validation of already-well-typed values).
|
||||
/// SF-1 (OP7 review fix, 2026-08-11): validates the raw JSON key STRING
|
||||
/// ordinally against <see cref="AllowedCharacterOptionNames"/> — never
|
||||
/// <c>Enum.TryParse</c>'s parsed value, which accepts numeric strings and
|
||||
/// comma-combined member lists that are not allow-listed spellings at
|
||||
/// all (see that field's own doc).
|
||||
/// </summary>
|
||||
private static void ValidateCharacterOptions(HeadlessSessionDescriptor session)
|
||||
{
|
||||
|
|
@ -215,8 +240,7 @@ internal static class HeadlessConfigurationLoader
|
|||
|
||||
foreach (string name in declared.Keys)
|
||||
{
|
||||
if (!Enum.TryParse(name, ignoreCase: false, out CharacterOptionId id)
|
||||
|| !AllowedCharacterOptions.Contains(id))
|
||||
if (!AllowedCharacterOptionNames.Contains(name))
|
||||
{
|
||||
throw new HeadlessConfigurationException(
|
||||
$"Session '{session.Id}' characterOptions declares "
|
||||
|
|
@ -224,5 +248,29 @@ internal static class HeadlessConfigurationLoader
|
|||
+ "option name.");
|
||||
}
|
||||
}
|
||||
|
||||
// SF-2 (OP7 review fix, 2026-08-11): retail's own OnChanged mutual
|
||||
// exclusion (RuntimeCharacterState.TrySetOption's MF-2 recursive
|
||||
// clear) makes IgnoreFellowshipRequests and
|
||||
// FellowshipAutoAcceptRequests both-true unsatisfiable — turning one
|
||||
// ON always clears the other. A config declaring both true would
|
||||
// have the seeder re-diff and re-send on every single connect
|
||||
// forever, with neither declared value ever actually honoured.
|
||||
// Reject the contradiction at load, before it can reach the wire.
|
||||
if (declared.TryGetValue(
|
||||
nameof(CharacterOptionId.IgnoreFellowshipRequests), out bool ignoreFellowship)
|
||||
&& ignoreFellowship
|
||||
&& declared.TryGetValue(
|
||||
nameof(CharacterOptionId.FellowshipAutoAcceptRequests), out bool autoAcceptFellowship)
|
||||
&& autoAcceptFellowship)
|
||||
{
|
||||
throw new HeadlessConfigurationException(
|
||||
$"Session '{session.Id}' characterOptions declares both "
|
||||
+ $"'{nameof(CharacterOptionId.IgnoreFellowshipRequests)}' and "
|
||||
+ $"'{nameof(CharacterOptionId.FellowshipAutoAcceptRequests)}' "
|
||||
+ "as true; retail's own mutual exclusion makes that "
|
||||
+ "combination unsatisfiable — turning one on always clears "
|
||||
+ "the other.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -143,18 +143,15 @@ internal sealed class HeadlessProcessHost : IDisposable
|
|||
internal HeadlessProcessContentSnapshot? Content =>
|
||||
_content?.CaptureSnapshot();
|
||||
|
||||
// MF-1 (Campaign OP OP7 review fix, 2026-08-11): `with` copies every
|
||||
// record property that this method doesn't explicitly override, so a
|
||||
// future HeadlessSessionDescriptor property can never be silently
|
||||
// dropped here the way CharacterOptions previously was by the
|
||||
// hand-rolled six-of-seven-property object initializer.
|
||||
private static HeadlessSessionDescriptor WithAccount(
|
||||
HeadlessSessionDescriptor source,
|
||||
string account) =>
|
||||
new()
|
||||
{
|
||||
Id = source.Id,
|
||||
Endpoint = source.Endpoint,
|
||||
Account = account,
|
||||
Character = source.Character,
|
||||
Policy = source.Policy,
|
||||
Credential = source.Credential,
|
||||
};
|
||||
source with { Account = account };
|
||||
|
||||
internal Task<HeadlessExitCode> RunAsync(
|
||||
CancellationToken cancellationToken)
|
||||
|
|
|
|||
|
|
@ -209,8 +209,19 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting
|
|||
onDesiredComponents: null,
|
||||
onCharacterOptions: (options1, options2, trailerTruncated) =>
|
||||
{
|
||||
// MF-2 (Campaign OP OP7 review fix, 2026-08-11): a
|
||||
// trailer-truncated PlayerDescription's option words are
|
||||
// the parser's zero placeholders, not server truth (R3,
|
||||
// above). Installing them AND notifying subscribers let a
|
||||
// headless seeder diff against zero and flush it into
|
||||
// 0x01A1 even though HasServerSeed stayed armed from an
|
||||
// earlier complete seed. Root fix: on truncation, install
|
||||
// NOTHING and notify NO ONE — the words the caller last
|
||||
// had (real, seeded) remain current.
|
||||
if (trailerTruncated)
|
||||
return;
|
||||
character.Character.Options.Replace(
|
||||
options1, options2, armServerSeed: !trailerTruncated);
|
||||
options1, options2, armServerSeed: true);
|
||||
character.OnCharacterOptionsChanged?.Invoke(options1, options2);
|
||||
},
|
||||
clientTime: character.ClientTime,
|
||||
|
|
|
|||
|
|
@ -875,11 +875,18 @@ public sealed class RuntimeLiveEntitySessionController
|
|||
RuntimeWorldHostAcknowledgementStage.TerminalProjected);
|
||||
|
||||
_session.SendGameAction(GameActionLoginComplete.Build());
|
||||
_onLoginCompleteSent?.Invoke();
|
||||
transit.EndTeleport();
|
||||
_log(
|
||||
$"headless: portal complete generation={generation} "
|
||||
+ $"cell=0x{destination.CellId:X8}");
|
||||
// SF-3 (Campaign OP OP7 review fix, 2026-08-11): invoke AFTER the
|
||||
// teleport-completion tail, matching the other production call
|
||||
// site's shape (OnSpawned, above). The observer body is not
|
||||
// trivial — it runs the full diff, real SendGameActions, and
|
||||
// event-hub publication to bot policies — so a throw from it must
|
||||
// not abort transit.EndTeleport() with the retry token already
|
||||
// discarded.
|
||||
_onLoginCompleteSent?.Invoke();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue