The retail Options panel (Campaign OP) needs a Runtime-owned option map
covering all 53 PlayerOption ids and the real batched SetCharacterOptions
(0x01A1) blob before any UI can be built on top of it. Today's surface only
modeled 6 ListenTo*Chat ids and the 0x01A1 builder was a malformed 16-byte
stub (deleted at Campaign CH slice CH3, docs/research/2026-08-09-chat-side-
channels-vs-ace.md).
- CharacterOptionTable.cs: the ONE typed table, PlayerOption id (0x00..0x34)
-> (Options1/Options2 word, mask, IsAutoSave, ClientDefault), transcribed
from acclient.h's verbatim CharacterOption/CharacterOptions2/PlayerOption
enums and byte-verified against IsAutoSaveOption @0x0059A600 (the 21-id
auto-save table) and GetDefaultOptionValue @0x005D2A30 (the Defaults-
button table). Reconstructing CharacterOptions1/2 defaults from the
ClientDefault column independently reproduces 0x50C4A54A / 0x00008700,
cross-confirming the id-mask mapping. CharacterOptionId (SocialActions.cs)
widened from 6 to all 53 ids to match.
- RuntimeCharacterOptionsState: SetOptionBit now resolves through the full
table (was a 6-case switch). New TrySetOption is the ONE shared local-
write-then-send/dirty seam — mirrors CPlayerModule::OnChanged exactly:
write the bit locally first, then either send 0x0005 immediately (auto-
save ids) or MarkDirty for the batched blob, no-op on an unchanged value
(retail's own early-return) or an unmodeled id. New dirty model (IsDirty/
FirstDirtiedAt/MarkDirty/TryFlush/TryFlushIfAutoSaveDue) uses an injected
TimeProvider so it's fully unit-testable without a live clock.
- Both IRuntimeCharacterCommands.SetSingleOption adapters (Direct + Current)
now route through TrySetOption instead of duplicating the write; this
fixes the headless local-write gap the OP1 research flagged (the direct
adapter previously sent the wire message without writing the bit first,
same class of bug CH4 fixed for the graphical host). Both also reject an
id outside the table instead of silently accepting it. LiveSessionRuntime
Factory's SendSingleCharacterOption closure now delegates to the same
seam instead of duplicating write-then-send inline.
- New IRuntimeCharacterCommands.SaveOptions(generation) — the explicit
blob-flush verb (retail's SaveToServer(force: 0)) — wired end-to-end in
both adapters, including a new SaveCharacterOptionsRuntimeCmd on the
graphical router.
- SocialActions.BuildSetCharacterOptions + WorldSession.SendSetCharacterOptions:
the real PlayerModule::Pack body per the wire research's field-by-field
layout — header always 0x460 OR'd with 0x001/0x008 when shortcuts/desired
comps are non-empty, favorite spells always 8 lists, never sets 0x100 or
0x200. Echoes last-parsed shortcuts/favorites/desired-comps/spellbook
filters (via new CharacterOptionsBlobSource) instead of zeroing them.
Conformance: a hand-computed golden byte vector (not generated by the
builder under test — the CH3 builder died of tests that pinned a wrong
shape and looked green) plus a round-trip through PlayerDescriptionParser.
Contract deviation: the 480 s auto-save timer and the flush-before-logout
trigger are implemented as fully-tested pure state-machine logic
(TryFlushIfAutoSaveDue) but are NOT wired into either host's live per-frame
loop or graceful-shutdown sequence in this slice — only the explicit
SaveOptions verb is production-wired. Wiring the timer touches App's
UpdateFrameOrchestrator graph and Headless's tick loop (outside this
slice's Runtime/wire-layer scope); wiring logout risks the already-fragile
graceful-shutdown sequence CLAUDE.md flags. Filed as TS-71 per the plan's
own escape valve ("target: not deferred" with a register row if deferred).
Also filed: AP-193 (the 0x34 HearPKDeathMessages id/mask is ACE-sourced,
unverifiable against the 2013 binary) and AP-194 (GetDefaultOptionValue's
table disagrees with the constructor default for ConfirmVolatileRareUse/
ShowHelm/ShowCloak — retail's own quirk, reproduced not fixed).
Tests: table completeness x53, auto-save/client-default split pinned
id-by-id against the byte-verified tables, unknown/reserved-id rejection
(0x35/0x36 landmines), local-write-then-send on both adapters + the router,
the dirty/flush state machine, SaveOptions, and the wire golden vector +
PlayerDescriptionParser round-trip. Full Release suite: 12,745 passed / 4
skipped / 0 failed (baseline 12,611/4/0 — slice adds 134 passing tests,
zero regressions).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
601 lines
21 KiB
C#
601 lines
21 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);
|
|
|
|
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)));
|
|
|
|
// A clean module's second SaveOptions sends nothing more.
|
|
RuntimeCommandResult savedAgain =
|
|
adapter.Character.SaveOptions(runtime.Generation);
|
|
Assert.True(savedAgain.Accepted);
|
|
Assert.Single(gameActions);
|
|
runtime.Dispose();
|
|
}
|
|
|
|
private static (GameRuntime Runtime, DirectGameRuntimeCommandAdapter Adapter, FixtureSessionOperations Operations)
|
|
CreateStartedHarness()
|
|
{
|
|
var operations = new FixtureSessionOperations();
|
|
var gameplay = new FixtureGameplayOperations();
|
|
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);
|
|
_ = 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()
|
|
{
|
|
}
|
|
}
|
|
}
|