Closes the two mechanism-lens and blast-lens dual reviews of Campaign OP
slice OP1 (86c0a7e0): docs/research/2026-08-10-op1-review-mechanism.md and
docs/research/2026-08-10-op1-review-blast.md.
MUST-FIX M1 (blast): RuntimeCharacterOptionsState gains a HasServerSeed
latch, set by Replace (the PlayerDescription seed) and cleared by
ResetSession. TryFlush/TryFlushIfAutoSaveDue now refuse before the seed
arrives — closing the window where a bot (or, after this commit, the
timer/logout triggers) could flush client-default option words over a
character's real server-side options before any PlayerDescription ever
landed.
MUST-FIX 1 (mechanism): the 480 s auto-save timer and the pre-logoff
flush are now wired into production, closing TS-71 (retired). Both ride
LiveSessionController's own tick/stop transaction via two new hooks
(ConfigureAutoSaveTick/ConfigurePreLogoffFlush), wired once by
GameRuntime's constructor — a Runtime-internal change requiring zero
host edits, exactly as the review identified. The flush body talks to
WorldSession directly rather than through App's LiveSessionCommandRouter,
which is what keeps this off the S2 lock-order hazard (below). Filed
TS-73 for the two OnChanged side-effect cases (weather/day/combat-
target/fog) TrySetOption still doesn't model — pre-anchored to OP4's
Group B consumer binds.
SHOULD-FIX S2 (blast, prerequisite for MUST-FIX 1): TryFlush/
TryFlushIfAutoSaveDue no longer invoke the flush callback while holding
_dirtyGate — the decision is made and cleared under the lock, but the
callback itself runs outside it, closing the lock-inversion hazard the
natural timer wiring would have hit (Runtime tick's _dirtyGate-then-
_gate vs the router's _gate-then-_dirtyGate).
SHOULD-FIX MF-2 (mechanism): TrySetOption now ports the two
PlayerModule-state-mutating cases of CPlayerModule::OnChanged's local
side-effect switch — turning ON IgnoreFellowshipRequests or
FellowshipAutoAcceptRequests clears the other through a real recursive
TrySetOption call, reproducing retail's second 0x0005 (the clear's send
reaches the wire before the primary option's own send, matching the
nested-call order in the decomp). The signature widened from
Action sendAutoSave to Action<uint,bool> so the recursion can send a
different (id, value) than the caller's own; every production call site
now passes WorldSession.SendSetSingleCharacterOption directly.
SHOULD-FIX MF-3 (mechanism): a hand-transcribed 53-row (id, isOptions1,
mask) theory in CharacterOptionTableTests, independently re-derived from
acclient.h's PlayerOption/CharacterOption/CharacterOptions2 enums rather
than copied from CharacterOptionTable.cs — closes the one column with no
id-by-id pin. Also added the pairwise-distinctness check blast NOTE N7
named.
SHOULD-FIX S1 (blast): LiveSessionCommandRouterTests' CH3/CH4 regression
test now drives the REAL TrySetOption binding instead of a hand-rolled
SetOptionBit substitute that had silently drifted from production after
OP1.
SHOULD-FIX S3 (blast): RuntimeCharacterOwnershipSnapshot gains
OptionsAreClean (!Options.IsDirty), included in IsConverged — a module
whose two words happen to cycle back to their default bit pattern while
still dirty is now caught by the combined ownership ledger, not just by
OptionsAreDefaults.
SHOULD-FIX S4 (blast): SaveOptions no longer encodes "did it actually
flush" as PrimaryObjectId 1u/0u (which read as object guid 0x00000001 in
the K2 event stream). Both host adapters now report the identical shape
(Accepted, objectId 0) — the graphical host never could report this
anyway (LiveCommandBus.Publish has no return channel).
SHOULD-FIX S5 (blast): Replace (the server-seed arrival) now also clears
IsDirty/FirstDirtiedAt — a wholesale re-seed supersedes any pending
batched-but-unflushed local intent (retail's own PlayerModule has no
partial-merge path either), documented at the member.
SHOULD-FIX S6 (blast): a cross-check theory asserting CharacterOptionTable's
masks equal PlayerDescriptionParser.CharacterOptions1/2's independently
(the write path vs the read path TurbineChatMembershipGate/
RuntimeSettingsController consume) — guards the exact CH3 failure class.
Also fixed a real allocation regression found while landing MUST-FIX 1:
the naive per-tick flush closure would have allocated on EVERY
LiveSessionController.Tick() call regardless of dirty state, which broke
the K4 headless 30-session resource-envelope gate. GameRuntime.
FlushCharacterOptions now pre-checks Options.IsDirty (itself retail-
faithful — CPlayerModule::UseTime opens with the identical m_bDirty byte
compare) before allocating the flush closure, so the allocation only
happens on the rare tick that might actually flush.
Dispositions on findings not changed this round:
- Mechanism NOTE 6 / not independently re-flagged: a re-entrant MarkDirty
from inside a flush callback can still be erased by the trailing
"_isDirty = false" — pre-existing, unchanged by the S2 lock restructure
(same outcome whether the callback runs inside or outside the lock),
not reachable from any current caller, not a one-liner to close
correctly (needs a per-dirty-period generation token). Left as documented
in the review; worth closing before the Options panel ever flushes from
inside a change handler.
- Mechanism NOTE 9, blast N2/N3/N4/N5/N6/N8: informational or require
touching files this round doesn't otherwise edit (SocialActions.cs,
CharacterOptionsBlobSource.cs, GameRuntimeContractTests.cs) — left per
the "one-liner in a file already being edited" instruction.
Register: TS-71 retired (both remaining SetCharacterOptions flush
triggers now production-wired); TS-73 filed (the two unmodeled OnChanged
presentation-binding cases, pre-anchored to OP4).
Quality bar: Release build green; full solution suite 12,853 passed / 4
skipped / 0 failed (baseline 12,770/4/0 post-OP2 — 83 new tests added,
zero regressions).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
732 lines
27 KiB
C#
732 lines
27 KiB
C#
using System.Net;
|
|
using AcDream.Core.Combat;
|
|
using AcDream.Core.Items;
|
|
using AcDream.Core.Net;
|
|
using AcDream.Core.Net.Messages;
|
|
using AcDream.Core.Spells;
|
|
using AcDream.Runtime.Entities;
|
|
using AcDream.Runtime.Gameplay;
|
|
using AcDream.Runtime.Session;
|
|
|
|
namespace AcDream.Runtime.Tests.Session;
|
|
|
|
public sealed class DirectGameRuntimeCommandAdapterTests
|
|
{
|
|
[Fact]
|
|
public void DirectRouteSendsTypedChatAndPortalAndRejectsOldGeneration()
|
|
{
|
|
var operations = new FixtureSessionOperations();
|
|
var gameplay = new FixtureGameplayOperations();
|
|
using var runtime = new GameRuntime(new GameRuntimeDependencies(
|
|
gameplay,
|
|
gameplay,
|
|
gameplay,
|
|
gameplay,
|
|
SessionOperations: operations));
|
|
gameplay.Bind(runtime);
|
|
var resetHost = new FixtureResetHost();
|
|
DirectGameRuntimeCommandAdapter? adapter = null;
|
|
LiveSessionConnectOptions options = new(
|
|
true,
|
|
"127.0.0.1",
|
|
9000,
|
|
"account",
|
|
"password");
|
|
var live = new LiveSessionHost(
|
|
runtime.Session,
|
|
new LiveSessionHostBindings(
|
|
new LiveSessionRoutingFactories(
|
|
_ => new FixtureEventRoute(),
|
|
session => adapter!.CreateRoute(session)),
|
|
generation => runtime.ResetGeneration(
|
|
generation,
|
|
resetHost),
|
|
new LiveSessionSelectionBindings(
|
|
id => runtime.PlayerIdentity.ServerGuid = id,
|
|
_ => { },
|
|
runtime.CommunicationOwner.Chat.SetLocalPlayerGuid,
|
|
_ => { },
|
|
_ => { },
|
|
runtime.ActionOwner.Combat.Clear),
|
|
new LiveSessionEnteredWorldBindings(
|
|
_ => { },
|
|
() => { },
|
|
() => { },
|
|
_ => { },
|
|
() => { }),
|
|
(_, _, _) => { },
|
|
() => { }),
|
|
options);
|
|
adapter = new DirectGameRuntimeCommandAdapter(runtime, live);
|
|
var trace = new RuntimeTraceRecorder();
|
|
using IDisposable subscription = runtime.Subscribe(trace);
|
|
|
|
RuntimeSessionStartResult started =
|
|
adapter.Session.Start(runtime.Generation);
|
|
RuntimeGenerationToken firstGeneration = runtime.Generation;
|
|
var gameActions = new List<byte[]>();
|
|
operations.Sessions[^1].GameActionCapture =
|
|
body => gameActions.Add(body);
|
|
const uint selectedObject = 0x70000001u;
|
|
runtime.InventoryOwner.Objects.AddOrUpdate(new ClientObject
|
|
{
|
|
ObjectId = selectedObject,
|
|
Type = ItemType.Misc,
|
|
});
|
|
|
|
RuntimeCommandResult chat = adapter.Chat.Execute(
|
|
runtime.Generation,
|
|
new RuntimeChatCommand(
|
|
RuntimeChatChannel.Say,
|
|
"hello"));
|
|
RuntimeCommandResult portal = adapter.Portal.Execute(
|
|
runtime.Generation,
|
|
RuntimePortalCommand.RecallLifestone);
|
|
runtime.CommunicationOwner.TurbineChat.OnChannelsReceived(
|
|
allegianceRoom: 0x10u,
|
|
generalRoom: 0x11u,
|
|
tradeRoom: 0x12u,
|
|
lfgRoom: 0x13u,
|
|
roleplayRoom: 0x14u,
|
|
olthoiRoom: 0x15u,
|
|
societyRoom: 0x16u,
|
|
societyCelestialHandRoom: 0u,
|
|
societyEldrytchWebRoom: 0u,
|
|
societyRadiantBloodRoom: 0u);
|
|
RuntimeCommandResult[] stateAndWireCommands =
|
|
[
|
|
adapter.Selection.SelectObject(
|
|
runtime.Generation,
|
|
selectedObject),
|
|
adapter.Selection.Clear(runtime.Generation),
|
|
adapter.Movement.SetIntent(
|
|
runtime.Generation,
|
|
new MovementInput(Forward: true, Run: true)),
|
|
adapter.Movement.ClearIntent(runtime.Generation),
|
|
adapter.Movement.Execute(
|
|
runtime.Generation,
|
|
RuntimeMovementCommand.Stop),
|
|
adapter.Chat.Execute(
|
|
runtime.Generation,
|
|
new RuntimeChatCommand(
|
|
RuntimeChatChannel.Fellowship,
|
|
"group")),
|
|
adapter.Chat.Execute(
|
|
runtime.Generation,
|
|
new RuntimeChatCommand(
|
|
RuntimeChatChannel.General,
|
|
"global")),
|
|
// CH3 (2026-08-09): Roleplay's room id is populated (0x14u
|
|
// above) but RuntimeCharacterOptionsState's fresh default omits
|
|
// HearRoleplayChat (matches ACE's own CharacterOptions2.Default)
|
|
// — the membership gate must refuse LOCALLY (via AddText, "You
|
|
// are not listening to the Roleplay channel!") rather than
|
|
// silently sending or silently dropping.
|
|
adapter.Chat.Execute(
|
|
runtime.Generation,
|
|
new RuntimeChatCommand(
|
|
RuntimeChatChannel.Roleplay,
|
|
"should be refused")),
|
|
adapter.InventoryState.AddShortcut(
|
|
runtime.Generation,
|
|
new RuntimeShortcutCommand(0, 0x70000001u, 0u)),
|
|
adapter.InventoryState.RemoveShortcut(
|
|
runtime.Generation,
|
|
0),
|
|
adapter.Spellbook.AddFavorite(
|
|
runtime.Generation,
|
|
tabIndex: 0,
|
|
position: 0,
|
|
spellId: 7u),
|
|
adapter.Spellbook.RemoveFavorite(
|
|
runtime.Generation,
|
|
tabIndex: 0,
|
|
spellId: 7u),
|
|
adapter.Spellbook.SetFilter(
|
|
runtime.Generation,
|
|
filters: 3u),
|
|
adapter.Spellbook.ForgetSpell(
|
|
runtime.Generation,
|
|
spellId: 7u),
|
|
adapter.Spellbook.SetDesiredComponent(
|
|
runtime.Generation,
|
|
componentId: 11u,
|
|
amount: 3u),
|
|
adapter.Spellbook.ClearDesiredComponents(
|
|
runtime.Generation),
|
|
adapter.Character.Advance(
|
|
runtime.Generation,
|
|
new RuntimeAdvancementCommand(
|
|
RuntimeAdvancementKind.Attribute,
|
|
StatId: 1u,
|
|
Cost: 10u)),
|
|
adapter.Character.Advance(
|
|
runtime.Generation,
|
|
new RuntimeAdvancementCommand(
|
|
RuntimeAdvancementKind.Vital,
|
|
StatId: 2u,
|
|
Cost: 11u)),
|
|
adapter.Character.Advance(
|
|
runtime.Generation,
|
|
new RuntimeAdvancementCommand(
|
|
RuntimeAdvancementKind.Skill,
|
|
StatId: 3u,
|
|
Cost: 12u)),
|
|
adapter.Character.Advance(
|
|
runtime.Generation,
|
|
new RuntimeAdvancementCommand(
|
|
RuntimeAdvancementKind.TrainSkill,
|
|
StatId: 4u,
|
|
Cost: 1u)),
|
|
adapter.Character.SetSingleOption(
|
|
runtime.Generation,
|
|
optionId: 0x26u,
|
|
value: true),
|
|
adapter.Social.Execute(
|
|
runtime.Generation,
|
|
new RuntimeFriendCommand(
|
|
RuntimeFriendCommandKind.Add,
|
|
Name: "Friend")),
|
|
adapter.Social.Execute(
|
|
runtime.Generation,
|
|
new RuntimeFriendCommand(
|
|
RuntimeFriendCommandKind.Remove,
|
|
CharacterId: 0x50000003u)),
|
|
adapter.Social.Execute(
|
|
runtime.Generation,
|
|
new RuntimeFriendCommand(
|
|
RuntimeFriendCommandKind.Clear)),
|
|
adapter.Social.Execute(
|
|
runtime.Generation,
|
|
new RuntimeSquelchCommand(
|
|
RuntimeSquelchScope.Character,
|
|
Add: true,
|
|
CharacterId: 0x50000004u,
|
|
Name: "Muted")),
|
|
adapter.Social.Execute(
|
|
runtime.Generation,
|
|
new RuntimeSquelchCommand(
|
|
RuntimeSquelchScope.Account,
|
|
Add: true,
|
|
Name: "AccountMuted")),
|
|
adapter.Social.Execute(
|
|
runtime.Generation,
|
|
new RuntimeSquelchCommand(
|
|
RuntimeSquelchScope.Global,
|
|
Add: true,
|
|
MessageType: 2u)),
|
|
];
|
|
|
|
RuntimeSessionStartResult reconnected =
|
|
adapter.Session.Reconnect(runtime.Generation);
|
|
RuntimeCommandResult stale = adapter.Chat.Execute(
|
|
firstGeneration,
|
|
new RuntimeChatCommand(
|
|
RuntimeChatChannel.Say,
|
|
"stale"));
|
|
RuntimeCommandResult staleMovement =
|
|
adapter.Movement.SetIntent(
|
|
firstGeneration,
|
|
new MovementInput(Forward: true));
|
|
|
|
Assert.Equal(RuntimeSessionStartStatus.Connected, started.Status);
|
|
Assert.Equal(
|
|
RuntimeSessionStartStatus.Connected,
|
|
reconnected.Status);
|
|
Assert.True(chat.Accepted);
|
|
Assert.True(portal.Accepted);
|
|
Assert.All(
|
|
stateAndWireCommands,
|
|
result => Assert.Equal(
|
|
RuntimeCommandStatus.Accepted,
|
|
result.Status));
|
|
Assert.Equal(
|
|
RuntimeCommandStatus.StaleGeneration,
|
|
stale.Status);
|
|
Assert.Equal(
|
|
RuntimeCommandStatus.StaleGeneration,
|
|
staleMovement.Status);
|
|
Assert.False(runtime.MovementOwner.HasCommandInput);
|
|
Assert.True(gameActions.Count >= 20);
|
|
Assert.Contains(
|
|
runtime.CommunicationOwner.Chat.Snapshot(),
|
|
entry => entry.Text.Contains(
|
|
"not listening to the Roleplay channel",
|
|
StringComparison.OrdinalIgnoreCase));
|
|
Assert.Contains(
|
|
trace.Entries,
|
|
entry => entry.Kind == RuntimeTraceKind.Command
|
|
&& (entry.Code >> 16)
|
|
== (int)RuntimeCommandDomain.Chat
|
|
&& entry.Text == "hello");
|
|
Assert.Contains(
|
|
trace.Entries,
|
|
entry => entry.Kind == RuntimeTraceKind.Command
|
|
&& (entry.Code >> 16)
|
|
== (int)RuntimeCommandDomain.Portal);
|
|
Assert.Contains(
|
|
trace.Entries,
|
|
entry => entry.Kind == RuntimeTraceKind.Combat);
|
|
|
|
RuntimeTeardownAcknowledgement stopped =
|
|
adapter.Session.Stop(runtime.Generation);
|
|
Assert.True(stopped.IsComplete);
|
|
Assert.False(runtime.Session.IsInWorld);
|
|
}
|
|
|
|
// ── OP1 (Campaign OP, 2026-08-10): the headless local-write-then-send
|
|
// seam, SaveOptions, and unknown-id rejection ───────────────────────
|
|
|
|
[Fact]
|
|
public void SetSingleOption_AutoSaveId_WritesLocalBitBeforeTheWireSendFires()
|
|
{
|
|
(GameRuntime runtime, DirectGameRuntimeCommandAdapter adapter, FixtureSessionOperations operations) =
|
|
CreateStartedHarness();
|
|
uint? options2AtSendTime = null;
|
|
operations.Sessions[^1].GameActionCapture = _ =>
|
|
options2AtSendTime ??= runtime.CharacterOwner.Options.Options2;
|
|
|
|
// Options2 default (0x00948700) has HearGeneralChat (0x100) ON;
|
|
// toggling it OFF exercises the local-write-then-send ordering this
|
|
// slice fixed on the headless path (lane B §4.4 / lane C §7.4).
|
|
RuntimeCommandResult result = adapter.Character.SetSingleOption(
|
|
runtime.Generation,
|
|
(uint)CharacterOptionId.ListenToGeneralChat,
|
|
false);
|
|
|
|
Assert.True(result.Accepted);
|
|
Assert.NotNull(options2AtSendTime);
|
|
Assert.Equal(0u, options2AtSendTime!.Value & 0x00000100u);
|
|
Assert.Equal(
|
|
0u,
|
|
runtime.CharacterOwner.Options.Options2 & 0x00000100u);
|
|
runtime.Dispose();
|
|
}
|
|
|
|
[Fact]
|
|
public void SetSingleOption_BatchedId_MarksDirtyWithoutSendingAnything()
|
|
{
|
|
(GameRuntime runtime, DirectGameRuntimeCommandAdapter adapter, FixtureSessionOperations operations) =
|
|
CreateStartedHarness();
|
|
var gameActions = new List<byte[]>();
|
|
operations.Sessions[^1].GameActionCapture = body => gameActions.Add(body);
|
|
|
|
// AutoTarget (0x0D) is batched, default ON — flip it off.
|
|
RuntimeCommandResult result = adapter.Character.SetSingleOption(
|
|
runtime.Generation,
|
|
(uint)CharacterOptionId.AutoTarget,
|
|
false);
|
|
|
|
Assert.True(result.Accepted);
|
|
Assert.Empty(gameActions);
|
|
Assert.True(runtime.CharacterOwner.Options.IsDirty);
|
|
runtime.Dispose();
|
|
}
|
|
|
|
[Fact]
|
|
public void SetSingleOption_UnknownId_RejectsWithoutSendingAnything()
|
|
{
|
|
(GameRuntime runtime, DirectGameRuntimeCommandAdapter adapter, FixtureSessionOperations operations) =
|
|
CreateStartedHarness();
|
|
var gameActions = new List<byte[]>();
|
|
operations.Sessions[^1].GameActionCapture = body => gameActions.Add(body);
|
|
|
|
// 0x35 == CharacterOptions1Default — the whole-default-mask landmine
|
|
// (wire research §5.4.3), never a real option.
|
|
RuntimeCommandResult result = adapter.Character.SetSingleOption(
|
|
runtime.Generation,
|
|
0x35u,
|
|
true);
|
|
|
|
Assert.Equal(RuntimeCommandStatus.Rejected, result.Status);
|
|
Assert.Empty(gameActions);
|
|
runtime.Dispose();
|
|
}
|
|
|
|
[Fact]
|
|
public void SaveOptions_FlushesTheDirtyBlobThenNoOpsWhenClean()
|
|
{
|
|
(GameRuntime runtime, DirectGameRuntimeCommandAdapter adapter, FixtureSessionOperations operations) =
|
|
CreateStartedHarness();
|
|
var gameActions = new List<byte[]>();
|
|
operations.Sessions[^1].GameActionCapture = body => gameActions.Add(body);
|
|
// MUST-FIX M1 (OP1 review fix, 2026-08-11): seed the server truth
|
|
// (as a real session's PlayerDescription would) before the flush —
|
|
// otherwise TryFlush now refuses outright (see
|
|
// SaveOptions_RefusesBeforeServerSeed_EvenWhenDirty below).
|
|
SeedServerOptions(runtime);
|
|
|
|
adapter.Character.SetSingleOption(
|
|
runtime.Generation, (uint)CharacterOptionId.AutoTarget, false);
|
|
Assert.True(runtime.CharacterOwner.Options.IsDirty);
|
|
Assert.Empty(gameActions);
|
|
|
|
RuntimeCommandResult saved = adapter.Character.SaveOptions(runtime.Generation);
|
|
|
|
Assert.True(saved.Accepted);
|
|
Assert.False(runtime.CharacterOwner.Options.IsDirty);
|
|
byte[] blob = Assert.Single(gameActions);
|
|
Assert.Equal(
|
|
SocialActions.SetCharacterOptionsOpcode,
|
|
System.Buffers.Binary.BinaryPrimitives.ReadUInt32LittleEndian(
|
|
blob.AsSpan(8)));
|
|
// S4 (OP1 review fix, blast lens): PrimaryObjectId no longer encodes
|
|
// whether the flush actually fired — both hosts report the SAME
|
|
// shape (Accepted, objectId 0).
|
|
Assert.Equal(0u, saved.ResultObjectId);
|
|
|
|
// A clean module's second SaveOptions sends nothing more.
|
|
RuntimeCommandResult savedAgain =
|
|
adapter.Character.SaveOptions(runtime.Generation);
|
|
Assert.True(savedAgain.Accepted);
|
|
Assert.Equal(0u, savedAgain.ResultObjectId);
|
|
Assert.Single(gameActions);
|
|
runtime.Dispose();
|
|
}
|
|
|
|
[Fact]
|
|
public void SaveOptions_RefusesBeforeServerSeed_EvenWhenDirty_ThenSucceedsAfterSeed()
|
|
{
|
|
(GameRuntime runtime, DirectGameRuntimeCommandAdapter adapter, FixtureSessionOperations operations) =
|
|
CreateStartedHarness();
|
|
var gameActions = new List<byte[]>();
|
|
operations.Sessions[^1].GameActionCapture = body => gameActions.Add(body);
|
|
|
|
// MUST-FIX M1 (OP1 review fix, 2026-08-11): CreateStartedHarness's
|
|
// RuntimeCharacterOptionsState starts at CLIENT constructor defaults
|
|
// — no PlayerDescription has landed yet (HasServerSeed is false). A
|
|
// blob flush here would ship acdream's defaults over the
|
|
// character's real server-side options — the wipe class M1 closes.
|
|
adapter.Character.SetSingleOption(
|
|
runtime.Generation, (uint)CharacterOptionId.AutoTarget, false);
|
|
Assert.True(runtime.CharacterOwner.Options.IsDirty);
|
|
|
|
RuntimeCommandResult saved = adapter.Character.SaveOptions(runtime.Generation);
|
|
|
|
Assert.True(saved.Accepted);
|
|
Assert.Empty(gameActions);
|
|
// Nothing lost, nothing sent — the pending change is still pending.
|
|
Assert.True(runtime.CharacterOwner.Options.IsDirty);
|
|
|
|
// The server's real PlayerDescription lands. S5: the seed
|
|
// supersedes the (unsent) pending change, so dirty a FRESH change
|
|
// after the seed to prove the GATE — not the module — was what
|
|
// refused above.
|
|
SeedServerOptions(runtime);
|
|
Assert.False(runtime.CharacterOwner.Options.IsDirty);
|
|
adapter.Character.SetSingleOption(
|
|
runtime.Generation, (uint)CharacterOptionId.ShowTooltips, false);
|
|
|
|
RuntimeCommandResult savedAfterSeed =
|
|
adapter.Character.SaveOptions(runtime.Generation);
|
|
|
|
Assert.True(savedAfterSeed.Accepted);
|
|
Assert.Single(gameActions);
|
|
Assert.False(runtime.CharacterOwner.Options.IsDirty);
|
|
runtime.Dispose();
|
|
}
|
|
|
|
// ── MUST-FIX 1 (Campaign OP OP1 review fix, mechanism lens, 2026-08-11):
|
|
// end-to-end proof that GameRuntime's real wiring — not just
|
|
// LiveSessionController's hook mechanics in isolation
|
|
// (LiveSessionControllerTests.cs) — actually auto-flushes the batched
|
|
// blob from an ordinary Session.Tick() once the 480 s timer is due.
|
|
|
|
[Fact]
|
|
public void Session_Tick_AutoFlushesTheDirtyBlob_OnceThe480sTimerIsDue()
|
|
{
|
|
var clock = new ManualTimeProvider();
|
|
(GameRuntime runtime, DirectGameRuntimeCommandAdapter adapter, FixtureSessionOperations operations) =
|
|
CreateStartedHarness(clock);
|
|
var gameActions = new List<byte[]>();
|
|
operations.Sessions[^1].GameActionCapture = body => gameActions.Add(body);
|
|
SeedServerOptions(runtime);
|
|
|
|
adapter.Character.SetSingleOption(
|
|
runtime.Generation, (uint)CharacterOptionId.AutoTarget, false);
|
|
Assert.True(runtime.CharacterOwner.Options.IsDirty);
|
|
|
|
// Before the timer: an ordinary tick must not flush.
|
|
runtime.Session.Tick();
|
|
Assert.Empty(gameActions);
|
|
Assert.True(runtime.CharacterOwner.Options.IsDirty);
|
|
|
|
clock.Advance(RuntimeCharacterOptionsState.AutoSaveDelay + TimeSpan.FromSeconds(1));
|
|
|
|
runtime.Session.Tick();
|
|
|
|
byte[] blob = Assert.Single(gameActions);
|
|
Assert.Equal(
|
|
SocialActions.SetCharacterOptionsOpcode,
|
|
System.Buffers.Binary.BinaryPrimitives.ReadUInt32LittleEndian(
|
|
blob.AsSpan(8)));
|
|
Assert.False(runtime.CharacterOwner.Options.IsDirty);
|
|
runtime.Dispose();
|
|
}
|
|
|
|
[Fact]
|
|
public void Stop_AutoFlushesTheDirtyBlob_BeforeTheCharacterLogoffRequest()
|
|
{
|
|
(GameRuntime runtime, DirectGameRuntimeCommandAdapter adapter, FixtureSessionOperations operations) =
|
|
CreateStartedHarness();
|
|
var order = new List<string>();
|
|
operations.Sessions[^1].GameActionCapture = body =>
|
|
{
|
|
uint opcode = System.Buffers.Binary.BinaryPrimitives.ReadUInt32LittleEndian(
|
|
body.AsSpan(8));
|
|
if (opcode == SocialActions.SetCharacterOptionsOpcode)
|
|
order.Add("options-blob");
|
|
};
|
|
SeedServerOptions(runtime);
|
|
adapter.Character.SetSingleOption(
|
|
runtime.Generation, (uint)CharacterOptionId.AutoTarget, false);
|
|
Assert.True(runtime.CharacterOwner.Options.IsDirty);
|
|
|
|
RuntimeTeardownAcknowledgement stopped =
|
|
adapter.Session.Stop(runtime.Generation);
|
|
|
|
Assert.True(stopped.IsComplete);
|
|
Assert.Equal(["options-blob"], order);
|
|
runtime.Dispose();
|
|
}
|
|
|
|
private sealed class ManualTimeProvider : TimeProvider
|
|
{
|
|
private DateTimeOffset _now = new(2026, 8, 11, 0, 0, 0, TimeSpan.Zero);
|
|
|
|
public override DateTimeOffset GetUtcNow() => _now;
|
|
|
|
public void Advance(TimeSpan elapsed) => _now += elapsed;
|
|
}
|
|
|
|
private static void SeedServerOptions(GameRuntime runtime) =>
|
|
runtime.CharacterOwner.Options.Replace(
|
|
runtime.CharacterOwner.Options.Options1,
|
|
runtime.CharacterOwner.Options.Options2);
|
|
|
|
private static (GameRuntime Runtime, DirectGameRuntimeCommandAdapter Adapter, FixtureSessionOperations Operations)
|
|
CreateStartedHarness(TimeProvider? timeProvider = null)
|
|
{
|
|
var operations = new FixtureSessionOperations();
|
|
var gameplay = new FixtureGameplayOperations();
|
|
var runtime = new GameRuntime(new GameRuntimeDependencies(
|
|
gameplay,
|
|
gameplay,
|
|
gameplay,
|
|
gameplay,
|
|
TimeProvider: timeProvider,
|
|
SessionOperations: operations));
|
|
gameplay.Bind(runtime);
|
|
var resetHost = new FixtureResetHost();
|
|
DirectGameRuntimeCommandAdapter? adapter = null;
|
|
LiveSessionConnectOptions options = new(
|
|
true,
|
|
"127.0.0.1",
|
|
9000,
|
|
"account",
|
|
"password");
|
|
var live = new LiveSessionHost(
|
|
runtime.Session,
|
|
new LiveSessionHostBindings(
|
|
new LiveSessionRoutingFactories(
|
|
_ => new FixtureEventRoute(),
|
|
session => adapter!.CreateRoute(session)),
|
|
generation => runtime.ResetGeneration(
|
|
generation,
|
|
resetHost),
|
|
new LiveSessionSelectionBindings(
|
|
id => runtime.PlayerIdentity.ServerGuid = id,
|
|
_ => { },
|
|
runtime.CommunicationOwner.Chat.SetLocalPlayerGuid,
|
|
_ => { },
|
|
_ => { },
|
|
runtime.ActionOwner.Combat.Clear),
|
|
new LiveSessionEnteredWorldBindings(
|
|
_ => { },
|
|
() => { },
|
|
() => { },
|
|
_ => { },
|
|
() => { }),
|
|
(_, _, _) => { },
|
|
() => { }),
|
|
options);
|
|
adapter = new DirectGameRuntimeCommandAdapter(runtime, live);
|
|
_ = adapter.Session.Start(runtime.Generation);
|
|
return (runtime, adapter, operations);
|
|
}
|
|
|
|
private sealed class FixtureSessionOperations : ILiveSessionOperations
|
|
{
|
|
public List<WorldSession> Sessions { get; } = [];
|
|
|
|
public IPEndPoint ResolveEndpoint(string host, int port) =>
|
|
new(IPAddress.Loopback, port);
|
|
|
|
public WorldSession CreateSession(IPEndPoint endpoint)
|
|
{
|
|
var session = new WorldSession(
|
|
endpoint,
|
|
new FixtureTransport());
|
|
Sessions.Add(session);
|
|
return session;
|
|
}
|
|
|
|
public void Connect(
|
|
WorldSession session,
|
|
string user,
|
|
string password)
|
|
{
|
|
}
|
|
|
|
public CharacterList.Parsed GetCharacters(WorldSession session) =>
|
|
new(
|
|
0u,
|
|
[
|
|
new CharacterList.Character(
|
|
0x50000001u,
|
|
"Direct",
|
|
0u),
|
|
],
|
|
[],
|
|
11,
|
|
"account",
|
|
true,
|
|
true);
|
|
|
|
public void EnterWorld(
|
|
WorldSession session,
|
|
int activeCharacterIndex)
|
|
{
|
|
}
|
|
|
|
public void Tick(WorldSession session)
|
|
{
|
|
}
|
|
|
|
public void DisposeSession(WorldSession session) =>
|
|
session.Dispose();
|
|
}
|
|
|
|
private sealed class FixtureEventRoute : ILiveSessionEventRouting
|
|
{
|
|
public void Attach()
|
|
{
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
}
|
|
}
|
|
|
|
private sealed class FixtureResetHost : IRuntimeGenerationResetHost
|
|
{
|
|
public void RetireEntityProjection(RuntimeEntityRecord entity)
|
|
{
|
|
}
|
|
|
|
public void DrainEntityProjectionBoundary()
|
|
{
|
|
}
|
|
|
|
public void CompleteEntityProjectionRetirement()
|
|
{
|
|
}
|
|
}
|
|
|
|
private sealed class FixtureTransport : IWorldSessionTransport
|
|
{
|
|
public void Send(ReadOnlySpan<byte> datagram)
|
|
{
|
|
}
|
|
|
|
public void Send(
|
|
IPEndPoint remote,
|
|
ReadOnlySpan<byte> datagram)
|
|
{
|
|
}
|
|
|
|
public int Receive(
|
|
Span<byte> destination,
|
|
TimeSpan timeout,
|
|
out IPEndPoint? from)
|
|
{
|
|
from = null;
|
|
return -1;
|
|
}
|
|
|
|
public ValueTask<NetReceiveResult> ReceiveAsync(
|
|
Memory<byte> destination,
|
|
CancellationToken cancellationToken) =>
|
|
ValueTask.FromException<NetReceiveResult>(
|
|
new OperationCanceledException(cancellationToken));
|
|
|
|
public void Dispose()
|
|
{
|
|
}
|
|
}
|
|
|
|
private sealed class FixtureGameplayOperations
|
|
: IRuntimeCombatAttackOperations,
|
|
IRuntimeCombatTargetOperations,
|
|
IRuntimeCombatModeOperations,
|
|
IRuntimeSpellCastOperations
|
|
{
|
|
private GameRuntime? _runtime;
|
|
|
|
public void Bind(GameRuntime runtime) => _runtime = runtime;
|
|
public bool CanStartAttack() => false;
|
|
public void PrepareAttackRequest()
|
|
{
|
|
}
|
|
|
|
public bool SendAttack(AttackHeight height, float power) => false;
|
|
public void SendCancelAttack()
|
|
{
|
|
}
|
|
|
|
public bool IsDualWield => false;
|
|
public bool PlayerReadyForAttack => false;
|
|
public bool AutoRepeatAttack => false;
|
|
public bool AutoTarget => false;
|
|
public uint? SelectClosestTarget() => null;
|
|
public bool IsInWorld => _runtime?.Session.IsInWorld == true;
|
|
public IReadOnlyList<ClientObject> GetOrderedEquipment() => [];
|
|
public void NotifyExplicitCombatModeRequest()
|
|
{
|
|
}
|
|
|
|
public void SendChangeCombatMode(CombatMode mode)
|
|
{
|
|
}
|
|
|
|
public uint LocalPlayerId =>
|
|
_runtime?.PlayerIdentity.ServerGuid ?? 0u;
|
|
public bool CanSend => false;
|
|
public bool HasRequiredComponents(uint spellId) => false;
|
|
|
|
public bool IsTargetCompatible(
|
|
uint targetId,
|
|
SpellMetadata spell,
|
|
bool showMessage) => false;
|
|
|
|
public void StopCompletely()
|
|
{
|
|
}
|
|
|
|
public void SendUntargeted(uint spellId)
|
|
{
|
|
}
|
|
|
|
public void SendTargeted(uint targetId, uint spellId)
|
|
{
|
|
}
|
|
|
|
public void DisplayMessage(string message)
|
|
{
|
|
}
|
|
|
|
public void IncrementBusy()
|
|
{
|
|
}
|
|
}
|
|
}
|