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>
This commit is contained in:
parent
efe80d5a0d
commit
09cb548a32
9 changed files with 1278 additions and 5 deletions
|
|
@ -48,6 +48,24 @@ internal sealed class HeadlessSessionDescriptor
|
|||
|
||||
[JsonRequired]
|
||||
public HeadlessCredentialReference Credential { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Campaign OP slice OP7 (2026-08-11), D8: optional declared character-
|
||||
/// option overrides. Keys MUST be exact <c>CharacterOptionId</c> enum-
|
||||
/// member spellings drawn from the lane-B tier-1+tier-2 bot-relevant
|
||||
/// subset (docs/research/2026-08-10-character-options-map.md §5.2/§7.3)
|
||||
/// — <see cref="HeadlessConfigurationLoader"/> rejects everything else
|
||||
/// at load, before any name can reach the wire (ACE throws
|
||||
/// <c>KeyNotFoundException</c> server-side on an unmodelled id — set-
|
||||
/// character-options-wire.md §5.4.2). Dictionary VALUES bypass the
|
||||
/// loader's camelCase property-naming policy entirely (only C# property
|
||||
/// names go through that policy; JSON object keys inside a
|
||||
/// <c>Dictionary<string, TValue></c> are read verbatim), so the
|
||||
/// exact PascalCase enum spelling is what the config file must contain.
|
||||
/// <c>null</c> (the field entirely absent) and an empty object are both
|
||||
/// legal no-ops.
|
||||
/// </summary>
|
||||
public Dictionary<string, bool>? CharacterOptions { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class HeadlessEndpointDescriptor
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using AcDream.Core.Net.Messages;
|
||||
|
||||
namespace AcDream.Headless.Configuration;
|
||||
|
||||
|
|
@ -7,6 +8,54 @@ internal static class HeadlessConfigurationLoader
|
|||
{
|
||||
private const int CurrentVersion = 1;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign OP slice OP7 (2026-08-11), D8: the ONLY <c>characterOptions</c>
|
||||
/// key names a headless config may declare — the lane-B tier-1 (22, "server-
|
||||
/// honoured AND plausibly bot-relevant") plus tier-2 (4, "client-side but a
|
||||
/// bot still wants them") subset from
|
||||
/// docs/research/2026-08-10-character-options-map.md §5.2, transcribed as
|
||||
/// the matching <see cref="CharacterOptionTable"/>/<see cref="CharacterOptionId"/>
|
||||
/// enum-member spellings (research doc §5.2's own prose labels differ from
|
||||
/// the enum in a few spots — e.g. "IgnoreAllTradeRequests" is
|
||||
/// <see cref="CharacterOptionId.IgnoreTradeRequests"/>,
|
||||
/// "LetOtherPlayersGiveYouItems" is <see cref="CharacterOptionId.AllowGive"/>
|
||||
/// — the enum spelling wins, per §7.3's "match CharacterOptionTable's enum-
|
||||
/// member spellings" instruction). Every other real
|
||||
/// <see cref="CharacterOptionId"/> member (the presentation-only tier-3 set,
|
||||
/// §5.2) is deliberately excluded BY CONSTRUCTION — not merely undeclared.
|
||||
/// </summary>
|
||||
private static readonly HashSet<CharacterOptionId> AllowedCharacterOptions =
|
||||
[
|
||||
// Tier 1 (22).
|
||||
CharacterOptionId.IgnoreAllegianceRequests,
|
||||
CharacterOptionId.IgnoreFellowshipRequests,
|
||||
CharacterOptionId.IgnoreTradeRequests,
|
||||
CharacterOptionId.AllowGive,
|
||||
CharacterOptionId.FellowshipShareXP,
|
||||
CharacterOptionId.AcceptLootPermits,
|
||||
CharacterOptionId.FellowshipShareLoot,
|
||||
CharacterOptionId.FellowshipAutoAcceptRequests,
|
||||
CharacterOptionId.DisplayAllegianceLogonNotifications,
|
||||
CharacterOptionId.UseChargeAttack,
|
||||
CharacterOptionId.UseCraftSuccessDialog,
|
||||
CharacterOptionId.AutoRepeatAttack,
|
||||
CharacterOptionId.LeadMissileTargets,
|
||||
CharacterOptionId.UseFastMissiles,
|
||||
CharacterOptionId.ConfirmVolatileRareUse,
|
||||
CharacterOptionId.AppearOffline,
|
||||
CharacterOptionId.ListenToAllegianceChat,
|
||||
CharacterOptionId.ListenToGeneralChat,
|
||||
CharacterOptionId.ListenToTradeChat,
|
||||
CharacterOptionId.ListenToLFGChat,
|
||||
CharacterOptionId.ListenToRoleplayChat,
|
||||
CharacterOptionId.ListenToSocietyChat,
|
||||
// Tier 2 (4).
|
||||
CharacterOptionId.MainPackPreferred,
|
||||
CharacterOptionId.ToggleRun,
|
||||
CharacterOptionId.AutoTarget,
|
||||
CharacterOptionId.SalvageMultiple,
|
||||
];
|
||||
|
||||
private static readonly JsonSerializerOptions Options = new()
|
||||
{
|
||||
AllowTrailingCommas = false,
|
||||
|
|
@ -144,5 +193,36 @@ internal static class HeadlessConfigurationLoader
|
|||
throw new HeadlessConfigurationException(
|
||||
$"Session '{session.Id}' requires a credential reference.");
|
||||
}
|
||||
|
||||
ValidateCharacterOptions(session);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// D8: an unknown name (misspelled, or a real
|
||||
/// <see cref="CharacterOptionId"/> outside <see cref="AllowedCharacterOptions"/>,
|
||||
/// e.g. a presentation-only tier-3 row) fails load with the offending
|
||||
/// name in the message — never silently dropped, never sent. A non-bool
|
||||
/// JSON value fails earlier, during <c>Dictionary<string, bool></c>
|
||||
/// deserialization itself (a <see cref="JsonException"/>, consistent with
|
||||
/// 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).
|
||||
/// </summary>
|
||||
private static void ValidateCharacterOptions(HeadlessSessionDescriptor session)
|
||||
{
|
||||
if (session.CharacterOptions is not { } declared)
|
||||
return;
|
||||
|
||||
foreach (string name in declared.Keys)
|
||||
{
|
||||
if (!Enum.TryParse(name, ignoreCase: false, out CharacterOptionId id)
|
||||
|| !AllowedCharacterOptions.Contains(id))
|
||||
{
|
||||
throw new HeadlessConfigurationException(
|
||||
$"Session '{session.Id}' characterOptions declares "
|
||||
+ $"'{name}', which is not a bot-declarable character "
|
||||
+ "option name.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue