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:
Erik 2026-08-11 02:45:08 +02:00
parent efe80d5a0d
commit 09cb548a32
9 changed files with 1278 additions and 5 deletions

View file

@ -1,6 +1,6 @@
# Campaign OP connected-gate test script
**Status:** OP3 section only. Later slices (OP4-6, OP8) append their own
**Status:** OP3 and OP7 sections. Later slices (OP4-6, OP8) append their own
sections here as they land; the campaign's OP9 closeout gate is this
document complete plus every slice code-complete.
@ -112,3 +112,126 @@ for this slice, not a bug.
- Configure Keyboard's actual screen — OP8.
- Any observable camera change from "Use Mouse Turning Settings" — no
acdream consumer exists yet (TS-74).
---
## OP7 — headless `characterOptions`
Unlike OP3-OP6, this is not a graphical-client gate: no window is launched.
The coordinator runs `acdream-headless run` against local ACE with a config
declaring a small `characterOptions` block, and inspects the ACE-side
persisted `CharacterOptions1`/`CharacterOptions2` (and, for the
`ListenTo*Chat` ids, the actual Turbine room membership) before and after.
Automated coverage (schema rejection, the diff-and-send engine, the wiring
from a real `PlayerDescription` game event through to a captured wire
action, and dedicated-update-thread affinity) already runs in
`tests/AcDream.Headless.Tests/HeadlessConfigurationLoaderTests.cs`,
`HeadlessCharacterOptionsSeederTests.cs`, and
`HeadlessCharacterOptionsSeederWiringTests.cs` — this script is for the ONE
thing those tests cannot prove: that a REAL ACE server actually accepts and
persists the sends.
### Recipe
1. **Confirm the character's starting state.** Before running the bot,
note (or reset) the target character's `IgnoreAllegianceRequests`
(0x01, auto-save, `SetSingleCharacterOption 0x0005`) and
`ListenToTradeChat` (0x24, auto-save, also `0x0005` — additionally
joins/leaves the Turbine trade room) options — e.g. via an existing
graphical login, or by inspecting ACE's stored `CharacterOptions1`/
`CharacterOptions2` for the character row directly. Pick a THIRD,
batched (non-auto-save) id for the blob path — `SalvageMultiple`
(0x22, tier 2) is a safe choice: it has no observable server-side
effect beyond the stored bit, so a mismatch is purely a persistence
question, not a behavior one.
2. **Author a headless config** with all three declared at values that
DIFFER from the character's current stored state, e.g.:
```json
{
"version": 1,
"sessions": [{
"id": "op7-bot",
"endpoint": { "host": "127.0.0.1", "port": 9000 },
"account": "testaccount",
"character": { "name": "+Acdream" },
"policy": { "id": "idle" },
"credential": { "provider": "environment", "reference": "OP7_BOT_PASSWORD" },
"characterOptions": {
"IgnoreAllegianceRequests": true,
"ListenToTradeChat": true,
"SalvageMultiple": true
}
}]
}
```
(Flip each `true` to `false` instead if the character already has that
bit set — the point is a genuine diff in both directions, not
specifically "everything ON".)
3. **Run it**: `acdream-headless run --config op7-bot.json` with
`OP7_BOT_PASSWORD` set in the environment. Let it sit in-world a few
seconds, then stop it (Ctrl+C — the process scheduler's graceful-
shutdown path already sends a real logoff).
### Expected wire sends on first connect
- One `SetSingleCharacterOption (0x0005)` for `IgnoreAllegianceRequests`
(id `0x01`) — immediately, no batching.
- One `SetSingleCharacterOption (0x0005)` for `ListenToTradeChat` (id
`0x24`) — immediately, and ACE's handler additionally joins the
Turbine trade-chat room server-side for this session (observable via
ACE's own Turbine-chat membership logging if available).
- One `SetSingleCharacterOption (0x0005)` for `SalvageMultiple` (id
`0x22`) followed by exactly one `SetCharacterOptions (0x01A1)` blob
flush — `SalvageMultiple` is batched (dirties the module; OP7's
diff-and-send calls the explicit `SaveOptions` verb once, after every
declared id has been diffed, never interleaved).
- **No other option bits change.** ACE's stored `CharacterOptions1`/
`CharacterOptions2` for every UNDECLARED id stay exactly what they
were before the run.
- **No 0x01A1 blob before the client's own `GameActionLoginComplete`.**
If ACE logs a refusal ("SetCharacterOptions received before
FirstEnterWorldDone" or equivalent), that is a real OP7 defect — the
diff-and-send is contracted to run only after both LoginComplete has
been sent AND a real `PlayerDescription` has seeded local state
(`HeadlessCharacterOptionsSeeder`'s own two-precondition latch).
### Expected silence on reconnect
4. **Run the SAME config a second time** (a fresh process, or the
process's own reconnect path if it fires) without changing anything
on the ACE side in between. Expect **zero** `0x0005`/`0x01A1` sends
for the three declared ids — the fresh `PlayerDescription` now echoes
back exactly what the first run persisted, so the declared-vs-actual
diff finds nothing (idempotent by construction: a bot that already
has what it wants sends nothing, matching retail's own "re-set to the
current value produces nothing" rule — set-character-options-wire.md
§3.1/§3.5).
5. **Flip one declared value** in the config (e.g. `SalvageMultiple` back
to `false`) and run again. Expect exactly ONE `0x0005` +
`0x01A1` pair for that id only — the other two, still matching,
produce nothing.
### What to report
- Any declared id that does NOT persist across a fresh reconnect (ACE
either rejected it silently, or the diff-and-send never actually ran).
- Any UNDECLARED id whose stored value changed — a sign the blob echoed
something it shouldn't have (wire research §5.3's "echo real values,
never zero them" rule, or a stale/incorrect snapshot read).
- Any 0x0005/0x01A1 send observed BEFORE the client's own LoginComplete
action on the wire (a WireMCP capture on loopback `127.0.0.1:9000`
settles this precisely if ACE's own log line is ambiguous).
- Whether `ListenToTradeChat` actually joined the Turbine trade room
server-side (not just the stored bit) — the one declared id in the
sample config with a real behavioral consumer.
### Explicitly NOT in scope for this gate
- Every other tier-1/tier-2 option name — the three above exercise both
wire paths (auto-save `0x0005`-only and batched `0x0005`+`0x01A1`);
the remaining 23 names share the same two code paths and are already
covered by the id-ascending completeness assertions in
`CharacterOptionTableTests` (OP1) and the schema tests here (OP7).
- Any presentation-only (tier-3) option — `HeadlessConfigurationLoader`
refuses to load a config that declares one; there is nothing to run.
- The graphical Options panel's own Character tab — OP4.