Slice 4 made a remote character's wielded weapon selectable, which made the
pickup chain reachable end to end for the first time: SelectionPickUp on
another player's weapon captured identity, passed ValidatePickupTarget (which
checked only the Stuck flag and the small-item mask, and a MeleeWeapon clears
both), installed a real non-autonomous approach through
PlayerInteractionMovementSink, and then sent a pickup request the server
rejects. Retail does none of that.
ItemHolder::AttemptToPlaceInContainer @ 0x00588140 runs
AttemptToPlaceInContainer_IsItemLegal @ 0x005870C0 first, at 0x00588173 --
ahead of container legality, auto-merge, the container walk, and the only
CM_Inventory::Event_PutItemInContainer emitter
(ACCWeenieObject::UIAttemptPutInContainer @ 0x0058D680). IsItemLegal's arm at
0x005872B7 rejects `!ACCWeenieObject::IsOwnedByPlayer(item) &&
item->pwd._location != 0` with one local
ECM_UI::SendNotice_DisplayStringInfo(0x1a, ...), and
CPlayerSystem::PlaceInBackpack @ 0x0055D8C0 then withdraws the waiting slot it
had published (SetWaitingState(obj, 0) + SendNotice_EndPendingInPlayer at
0x0055D918). No request, no movement. acdream had never ported that arm; it
was harmless while wielded children were unpickable and stopped being harmless
at f6db964f.
The notice is data_7e2228, "The %s is being wielded by someone else!" -- WITH
the exclamation mark. IsItemLegal's six strings occupy one contiguous literal
block, 0x007e21f0 through 0x007e234c, one per arm in reverse code order, and
the two neighbours already ported here (0x007e227c "The %s cannot be picked
up!" at 0x00587264, 0x007e22b4 "You cannot pick up creatures!" at 0x005871f4)
pin it. The punctuation-free 0x007cd350 variant belongs to the wield/wear
block and is emitted from a different function at 0x00560aef.
pwd._location is the PublicWeenieDesc CurrentWieldedLocation field
(acclient.h:37175), which acdream projects as
ClientObject.CurrentlyEquippedLocation, and ACCWeenieObject::IsOwnedByPlayer
@ 0x0058D160 is IsOwnedByObject(this, player_id) -- already ported as
ClientObjectTable.IsOwnedByObject @ 0x0058CEB0 and reached here through the
existing ItemInteractionController.IsOwnedByPlayer. The arm reads pwd._location
verbatim rather than adding a WielderId belt-and-braces test, because retail's
predicate is the thing being ported.
The player's OWN wielded item is IsOwnedByPlayer, so retail passes it and takes
a different route. ACCWeenieObject::DeterminePositionState @ 0x0058BE70 gives
it PositionState.WIELDED (acclient.h:6802) rather than IN_3D_VIEW, and
UIAttemptPutInContainer records IR_PICK_UP only for IN_3D_VIEW, treating
WIELDED and IN_CONTAINER alike as a plain IR_PUT_IN_CONTAINER transfer. So an
own-wielded item is unwielded in place: the request goes out immediately with
no approach, joining the existing current-ground-object shortcut. The shortcut
carries an ownership conjunct so it can never outrun the 0x005872B7 gate.
TryGetApproach now refuses attached children outright, for the same
IN_3D_VIEW reason. An Attached projection's bookkeeping WorldEntity.Position
carries the PARENT's composed root (EquippedChildRenderController
.ApplyParentWorldPose), not the child frame CPhysicsObj::UpdateChild @
0x00512D50 composes, so an approach built from it walked toward the wielder.
Slice 4 de-parented the marker anchor but left this one parent-derived; no
approach can anchor on a wielder now.
The pick predicates are deliberately untouched. Picking, selecting, examining,
lighting-pulse identity, and the vivid-marker anchor on a remote's wielded
weapon all behave exactly as Slice 4 shipped them -- retail's sr_Select and
sr_Examine branches of RecvNotice_SmartBoxObjectFound @ 0x004E5AD0 never
consult IsItemLegal. The gate is the transaction, not the pick.
f6db964f's message asserted the slice introduced no deviation and owed no
retail-divergence-register row. That was wrong: the unported 0x005872B7 arm
was a deviation it made reachable. This commit ports the arm in full, matches
retail on the own-wielded path, and removes the parent-derived approach
anchor, so the record is corrected here and no register row is owed.
Gates: dotnet build green; AcDream.App.Tests 3,960 passed / 3 skipped;
complete Release solution 9,792 passed / 5 skipped;
tools\run-connected-world-lifecycle-gate.ps1 -SkipBuild RESULT=PASS.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1349 lines
48 KiB
C#
1349 lines
48 KiB
C#
using System.Net;
|
|
using System.Numerics;
|
|
using AcDream.App.Combat;
|
|
using AcDream.App.Input;
|
|
using AcDream.App.Interaction;
|
|
using AcDream.App.Net;
|
|
using AcDream.App.Runtime;
|
|
using AcDream.App.Spells;
|
|
using AcDream.App.Streaming;
|
|
using AcDream.App.UI;
|
|
using AcDream.App.Update;
|
|
using AcDream.App.World;
|
|
using AcDream.Core.Chat;
|
|
using AcDream.Core.Items;
|
|
using AcDream.Core.Net;
|
|
using AcDream.Core.Net.Messages;
|
|
using AcDream.Core.Physics;
|
|
using AcDream.Core.Selection;
|
|
using AcDream.Core.Social;
|
|
using AcDream.Core.Spells;
|
|
using AcDream.Core.World;
|
|
using AcDream.Runtime;
|
|
using AcDream.Runtime.Entities;
|
|
using AcDream.Runtime.Gameplay;
|
|
using AcDream.Runtime.Session;
|
|
using AcDream.Runtime.World;
|
|
using AcDream.UI.Abstractions;
|
|
using AcDream.UI.Abstractions.Input;
|
|
|
|
namespace AcDream.App.Tests.Runtime;
|
|
|
|
public sealed class CurrentGameRuntimeAdapterTests
|
|
{
|
|
[Fact]
|
|
public void AdapterBorrowsCurrentOwnersAndCommandsExecuteAtPressTime()
|
|
{
|
|
using var harness = new Harness();
|
|
var trace = new RuntimeTraceRecorder();
|
|
using IDisposable subscription = harness.Runtime.Subscribe(trace);
|
|
RuntimeGenerationToken initial = harness.Runtime.Generation;
|
|
|
|
RuntimeSessionStartResult start = harness.Runtime.Session.Start(initial);
|
|
|
|
Assert.Equal(RuntimeSessionStartStatus.Connected, start.Status);
|
|
Assert.Equal(new RuntimeGenerationToken(1), start.Generation);
|
|
Assert.Equal(Harness.PlayerGuid, harness.Identity.ServerGuid);
|
|
Assert.Equal(RuntimeLifecycleState.InWorld, harness.Runtime.Lifecycle.State);
|
|
Assert.Same(harness.Actions.View, harness.Runtime.Actions);
|
|
|
|
LiveEntityRecord liveRecord =
|
|
harness.Entities.RegisterAndMaterializeProjection(Spawn(
|
|
Harness.TargetGuid,
|
|
instance: 3,
|
|
cell: 0x12340001u));
|
|
Assert.NotNull(liveRecord.WorldEntity);
|
|
|
|
var item = new ClientObject
|
|
{
|
|
ObjectId = Harness.TargetGuid,
|
|
Name = "Runtime fixture",
|
|
StackSize = 4,
|
|
Value = 25,
|
|
ContainerId = Harness.PlayerGuid,
|
|
ContainerSlot = 2,
|
|
};
|
|
harness.Objects.AddOrUpdate(item);
|
|
harness.Chat.OnSystemMessage("runtime parity", 0x1Au);
|
|
RuntimeWorldTransitTestDriver.AcceptPortalDestination(
|
|
harness.WorldTransit,
|
|
0x12340001u);
|
|
Assert.True(harness.WorldReveal.TryBeginPortal(
|
|
1,
|
|
0x12340001u,
|
|
out _));
|
|
WorldRevealReadinessSnapshot readiness =
|
|
harness.WorldReveal.PrepareAndEvaluate(0x12340001u);
|
|
Assert.True(readiness.IsReady);
|
|
harness.Clock.Advance(new UpdateFrameInput(0.125));
|
|
|
|
RuntimeGenerationToken generation = harness.Runtime.Generation;
|
|
RuntimeCommandResult selection = harness.Runtime.Selection.Execute(
|
|
generation,
|
|
RuntimeSelectionCommand.SelectClosestHostile);
|
|
RuntimeCommandResult movement =
|
|
((IGameRuntimeCommands)harness.Runtime).Movement.Execute(
|
|
generation,
|
|
RuntimeMovementCommand.ToggleRunLock);
|
|
RuntimeCommandResult combat = harness.Runtime.Combat.Execute(
|
|
generation,
|
|
RuntimeCombatCommand.ToggleMode);
|
|
RuntimeCommandResult chat =
|
|
((IGameRuntimeCommands)harness.Runtime).Chat.Execute(
|
|
generation,
|
|
new RuntimeChatCommand(
|
|
RuntimeChatChannel.General,
|
|
"hello runtime"));
|
|
RuntimeCommandResult portal =
|
|
((IGameRuntimeCommands)harness.Runtime).Portal.Execute(
|
|
generation,
|
|
RuntimePortalCommand.RecallLifestone);
|
|
|
|
Assert.True(selection.Accepted);
|
|
Assert.True(movement.Accepted);
|
|
Assert.True(combat.Accepted);
|
|
Assert.True(chat.Accepted);
|
|
Assert.True(portal.Accepted);
|
|
Assert.Equal(Harness.TargetGuid, harness.Selection.SelectedObjectId);
|
|
Assert.True(harness.MovementInput.AutoRunActive);
|
|
Assert.Equal(
|
|
[AcDream.Core.Combat.CombatMode.Melee],
|
|
harness.CombatMode.Sent);
|
|
Assert.Equal(
|
|
AcDream.Core.Combat.CombatMode.Melee,
|
|
harness.Actions.Combat.CurrentMode);
|
|
Assert.Contains(
|
|
harness.Commands.Published,
|
|
static command => command is SendChatCmd
|
|
{
|
|
Channel: ChatChannelKind.General,
|
|
Text: "hello runtime",
|
|
});
|
|
Assert.Contains(
|
|
harness.Commands.Published,
|
|
static command => command is ExecuteClientCommandCmd
|
|
{
|
|
Command: ClientCommandId.LifestoneRecall,
|
|
});
|
|
|
|
RuntimeStateCheckpoint checkpoint = harness.Runtime.CaptureCheckpoint();
|
|
Assert.Equal(1, checkpoint.EntityCount);
|
|
Assert.Equal(1, checkpoint.InventoryObjectCount);
|
|
Assert.Equal(1, checkpoint.ChatCount);
|
|
Assert.Equal(1L, checkpoint.ChatRevision);
|
|
Assert.Equal(1UL, checkpoint.FrameNumber);
|
|
Assert.Equal(
|
|
Harness.TargetGuid,
|
|
checkpoint.Actions.SelectedObjectId);
|
|
Assert.Equal(1, checkpoint.Actions.SelectionRevision);
|
|
Assert.Equal(RuntimePortalKind.Portal, checkpoint.Portal.Kind);
|
|
Assert.Equal(0x12340001u, checkpoint.Portal.DestinationCell);
|
|
Assert.True(checkpoint.Portal.IsReady);
|
|
|
|
Assert.True(harness.Runtime.Entities.TryGet(
|
|
Harness.TargetGuid,
|
|
out RuntimeEntitySnapshot entity));
|
|
Assert.Equal((ushort)3, entity.Identity.Incarnation);
|
|
Assert.True(harness.Runtime.Inventory.TryGet(
|
|
Harness.TargetGuid,
|
|
out RuntimeInventoryItemSnapshot inventory));
|
|
Assert.Equal((ushort)3, inventory.Incarnation);
|
|
Assert.Equal(4, inventory.StackSize);
|
|
|
|
RuntimeTraceKind[] kinds = trace.Entries
|
|
.Select(static entry => entry.Kind)
|
|
.ToArray();
|
|
Assert.Equal(RuntimeTraceKind.Command, kinds[0]);
|
|
Assert.Equal(RuntimeTraceKind.Lifecycle, kinds[1]);
|
|
Assert.Contains(RuntimeTraceKind.Inventory, kinds);
|
|
Assert.Contains(RuntimeTraceKind.Chat, kinds);
|
|
Assert.Equal(
|
|
[
|
|
RuntimeCommandDomain.Session,
|
|
RuntimeCommandDomain.Selection,
|
|
RuntimeCommandDomain.Movement,
|
|
RuntimeCommandDomain.Combat,
|
|
RuntimeCommandDomain.Chat,
|
|
RuntimeCommandDomain.Portal,
|
|
],
|
|
trace.Entries
|
|
.Where(static entry => entry.Kind == RuntimeTraceKind.Command)
|
|
.Select(static entry =>
|
|
(RuntimeCommandDomain)(entry.Code >> 16))
|
|
.ToArray());
|
|
}
|
|
|
|
[Fact]
|
|
public void GenerationGateRejectsStaleCommandsAndStopAcknowledgesTeardown()
|
|
{
|
|
using var harness = new Harness();
|
|
RuntimeSessionStartResult start =
|
|
harness.Runtime.Session.Start(harness.Runtime.Generation);
|
|
Assert.Equal(RuntimeSessionStartStatus.Connected, start.Status);
|
|
RuntimeGenerationToken active = harness.Runtime.Generation;
|
|
|
|
RuntimeTeardownAcknowledgement stopped =
|
|
harness.Runtime.Session.Stop(active);
|
|
RuntimeCommandResult stale = harness.Runtime.Combat.Execute(
|
|
active,
|
|
RuntimeCombatCommand.ToggleMode);
|
|
|
|
Assert.True(stopped.IsComplete);
|
|
Assert.Equal(active, stopped.RetiredGeneration);
|
|
Assert.Equal(new RuntimeGenerationToken(2), stopped.CurrentGeneration);
|
|
Assert.Equal(RuntimeCommandStatus.StaleGeneration, stale.Status);
|
|
Assert.Empty(harness.CombatMode.Sent);
|
|
Assert.Equal(RuntimeLifecycleState.Stopped, harness.Runtime.Lifecycle.State);
|
|
}
|
|
|
|
[Fact]
|
|
public void GraphicalAndDirectCombatMagicCommands_ReachTheExactSameOwners()
|
|
{
|
|
using var graphical = new Harness();
|
|
using var direct = new Harness();
|
|
Assert.Equal(
|
|
RuntimeSessionStartStatus.Connected,
|
|
graphical.Runtime.Session.Start(graphical.Runtime.Generation).Status);
|
|
Assert.Equal(
|
|
RuntimeSessionStartStatus.Connected,
|
|
direct.Runtime.Session.Start(direct.Runtime.Generation).Status);
|
|
var directTrace = new RuntimeTraceRecorder();
|
|
using IDisposable directTraceSubscription =
|
|
direct.Runtime.Subscribe(directTrace);
|
|
|
|
var graphicalAttack = new RecordingAttackOperations();
|
|
var directAttack = new RecordingAttackOperations();
|
|
var graphicalSpell = new RecordingSpellOperations();
|
|
var directSpell = new RecordingSpellOperations();
|
|
using IDisposable graphicalAttackBinding =
|
|
graphical.CombatAttackOperations.BindOwned(graphicalAttack);
|
|
using IDisposable directAttackBinding =
|
|
direct.CombatAttackOperations.BindOwned(directAttack);
|
|
using IDisposable graphicalSpellBinding =
|
|
graphical.SpellCastOperations.BindOwned(graphicalSpell);
|
|
using IDisposable directSpellBinding =
|
|
direct.SpellCastOperations.BindOwned(directSpell);
|
|
|
|
PrepareCombatAndMagic(graphical);
|
|
PrepareCombatAndMagic(direct);
|
|
|
|
var graphicalInput =
|
|
new CombatAttackInputFrameAdapter(graphical.Actions.CombatAttack);
|
|
Assert.True(graphicalInput.HandleInputAction(
|
|
InputAction.CombatLowAttack,
|
|
ActivationType.Press));
|
|
Assert.True(graphicalInput.HandleInputAction(
|
|
InputAction.CombatLowAttack,
|
|
ActivationType.Release));
|
|
Assert.Equal(
|
|
CastRequestResult.Sent,
|
|
graphical.Actions.SpellCast.Cast(1u));
|
|
|
|
RuntimeGenerationToken generation = direct.Runtime.Generation;
|
|
Assert.True(direct.Runtime.Combat.ExecuteAttack(
|
|
generation,
|
|
new RuntimeCombatAttackInput(
|
|
RuntimeCombatAttackCommand.LowAttack,
|
|
RuntimeInputActivation.Press)).Accepted);
|
|
Assert.True(direct.Runtime.Combat.ExecuteAttack(
|
|
generation,
|
|
new RuntimeCombatAttackInput(
|
|
RuntimeCombatAttackCommand.LowAttack,
|
|
RuntimeInputActivation.Release)).Accepted);
|
|
Assert.True(direct.Runtime.Magic.Execute(
|
|
generation,
|
|
new RuntimeMagicCommand(1u)).Accepted);
|
|
|
|
Assert.Equal(graphicalAttack.Trace, directAttack.Trace);
|
|
Assert.Equal(graphicalSpell.Trace, directSpell.Trace);
|
|
Assert.Equal(
|
|
graphical.Actions.View.Snapshot.CombatAttack,
|
|
direct.Actions.View.Snapshot.CombatAttack);
|
|
Assert.Equal(
|
|
graphical.Actions.View.Snapshot.Magic,
|
|
direct.Actions.View.Snapshot.Magic);
|
|
Assert.Equal(
|
|
[
|
|
RuntimeCommandDomain.Combat,
|
|
RuntimeCommandDomain.Combat,
|
|
RuntimeCommandDomain.Magic,
|
|
],
|
|
directTrace.Entries
|
|
.Where(static entry => entry.Kind == RuntimeTraceKind.Command)
|
|
.Select(static entry =>
|
|
(RuntimeCommandDomain)(entry.Code >> 16))
|
|
.ToArray());
|
|
}
|
|
|
|
[Fact]
|
|
public void DirectAndGraphicalHosts_ProduceIdenticalEntityObjectTrace()
|
|
{
|
|
WorldSession.EntitySpawn spawn =
|
|
Spawn(Harness.TargetGuid, instance: 7, cell: 0x12340001u);
|
|
var direct = new RuntimeEntityObjectLifetime();
|
|
direct.BindEventContext(static () => default, static () => 0UL);
|
|
var directTrace = new EntityObjectTrace();
|
|
using IDisposable directSubscription =
|
|
direct.Events.Subscribe(directTrace);
|
|
|
|
RuntimeEntityRegistrationResult directRegistration =
|
|
direct.RegisterEntity(spawn);
|
|
RuntimeEntityRecord directCanonical =
|
|
Assert.IsType<RuntimeEntityRecord>(directRegistration.Canonical);
|
|
direct.Objects.AddOrUpdate(Object(spawn));
|
|
direct.Objects.MoveItem(
|
|
spawn.Guid,
|
|
Harness.PlayerGuid,
|
|
newSlot: 3);
|
|
var objDesc = new ObjDescEvent.Parsed(
|
|
spawn.Guid,
|
|
new CreateObject.ModelData(
|
|
0x04000001u,
|
|
Array.Empty<CreateObject.SubPaletteSwap>(),
|
|
Array.Empty<CreateObject.TextureChange>(),
|
|
Array.Empty<CreateObject.AnimPartChange>()),
|
|
spawn.InstanceSequence,
|
|
ObjDescSequence: 2);
|
|
Assert.True(direct.TryApplyObjDesc(
|
|
objDesc,
|
|
acknowledgeProjection: null,
|
|
out _));
|
|
var motion = new WorldSession.EntityMotionUpdate(
|
|
spawn.Guid,
|
|
new CreateObject.ServerMotionState(0x3D, 0x11),
|
|
spawn.InstanceSequence,
|
|
MovementSequence: 2,
|
|
ServerControlSequence: 2,
|
|
IsAutonomous: false);
|
|
Assert.True(direct.TryApplyMotion(
|
|
motion,
|
|
retainPayload: true,
|
|
acknowledgeProjection: null,
|
|
out _,
|
|
out _));
|
|
var vector = new VectorUpdate.Parsed(
|
|
spawn.Guid,
|
|
new Vector3(1f, 2f, 3f),
|
|
new Vector3(0f, 0f, 0.25f),
|
|
spawn.InstanceSequence,
|
|
VectorSequence: 2);
|
|
Assert.True(direct.TryApplyVector(
|
|
vector,
|
|
acknowledgeProjection: null,
|
|
out _));
|
|
var state = new SetState.Parsed(
|
|
spawn.Guid,
|
|
(uint)(PhysicsStateFlags.ReportCollisions
|
|
| PhysicsStateFlags.Hidden),
|
|
spawn.InstanceSequence,
|
|
StateSequence: 2);
|
|
Assert.True(direct.TryApplyState(
|
|
state,
|
|
acknowledgeProjection: null,
|
|
out _,
|
|
out _));
|
|
Assert.True(direct.CommitChildNoDraw(
|
|
directCanonical,
|
|
noDraw: true));
|
|
Assert.True(direct.CommitChildNoDraw(
|
|
directCanonical,
|
|
noDraw: false));
|
|
WorldSession.EntityPositionUpdate position =
|
|
Position(spawn.Guid, spawn.InstanceSequence);
|
|
Assert.True(direct.TryApplyPosition(
|
|
position,
|
|
isLocalPlayer: false,
|
|
forcePositionRotation: null,
|
|
currentLocalVelocity: null,
|
|
projectionRequiresTeleportHook: false,
|
|
acknowledgeProjection: null,
|
|
out _,
|
|
out _,
|
|
out _));
|
|
Assert.True(direct.CommitRebucket(
|
|
directCanonical,
|
|
0x12360001u,
|
|
0x1236FFFFu));
|
|
Assert.True(direct.TryApplyPickup(
|
|
new PickupEvent.Parsed(
|
|
spawn.Guid,
|
|
spawn.InstanceSequence,
|
|
PositionSequence: 3),
|
|
acknowledgeProjection: null,
|
|
out _));
|
|
Assert.True(direct.TryAcceptDelete(
|
|
new DeleteObject.Parsed(spawn.Guid, spawn.InstanceSequence),
|
|
isLocalPlayer: false,
|
|
removeRetainedObject: true,
|
|
out RuntimeEntityDeleteAcceptance directDelete));
|
|
direct.CompleteAcceptedDelete(directDelete);
|
|
Assert.Null(direct.RetireCanonicalOnly(directCanonical));
|
|
|
|
using var graphical = new Harness();
|
|
var graphicalTrace = new EntityObjectTrace();
|
|
using IDisposable graphicalSubscription =
|
|
graphical.EntityObjects.Events.Subscribe(graphicalTrace);
|
|
_ = graphical.Entities.RegisterAndMaterializeProjection(spawn);
|
|
graphical.Objects.AddOrUpdate(Object(spawn));
|
|
graphical.Objects.MoveItem(
|
|
spawn.Guid,
|
|
Harness.PlayerGuid,
|
|
newSlot: 3);
|
|
Assert.True(graphical.Entities.TryApplyObjDesc(
|
|
objDesc,
|
|
out _));
|
|
Assert.True(graphical.Entities.TryApplyMotion(
|
|
motion,
|
|
retainPayload: true,
|
|
out _,
|
|
out _));
|
|
Assert.True(graphical.Entities.TryApplyVector(vector, out _));
|
|
Assert.True(graphical.Entities.TryApplyState(state, out _, out _));
|
|
Assert.True(graphical.Entities.SetAttachedChildNoDraw(
|
|
spawn.Guid,
|
|
noDraw: true));
|
|
Assert.True(graphical.Entities.SetAttachedChildNoDraw(
|
|
spawn.Guid,
|
|
noDraw: false));
|
|
Assert.True(graphical.Entities.TryApplyPosition(
|
|
position,
|
|
isLocalPlayer: false,
|
|
forcePositionRotation: null,
|
|
currentLocalVelocity: null,
|
|
out _,
|
|
out _,
|
|
out _));
|
|
Assert.True(graphical.Entities.RebucketLiveEntity(
|
|
spawn.Guid,
|
|
0x12360001u));
|
|
Assert.True(graphical.Entities.TryApplyPickup(
|
|
new PickupEvent.Parsed(
|
|
spawn.Guid,
|
|
spawn.InstanceSequence,
|
|
PositionSequence: 3),
|
|
out _));
|
|
Assert.True(graphical.Entities.UnregisterLiveEntity(
|
|
new DeleteObject.Parsed(spawn.Guid, spawn.InstanceSequence),
|
|
isLocalPlayer: false,
|
|
removeRetainedObject: true));
|
|
|
|
Assert.Equal(directTrace.Entries, graphicalTrace.Entries);
|
|
Assert.Equal(0, direct.Entities.Count);
|
|
Assert.Equal(0, direct.Objects.ObjectCount);
|
|
Assert.Equal(0, graphical.EntityObjects.Entities.Count);
|
|
Assert.Equal(0, graphical.Objects.ObjectCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void GraphicalObserverFailure_DoesNotStarveLaterObserverOrOwner()
|
|
{
|
|
using var harness = new Harness();
|
|
var throwing = new ThrowingEntityObserver();
|
|
var recording = new RuntimeTraceRecorder();
|
|
IDisposable first = harness.Runtime.Subscribe(throwing);
|
|
IDisposable second = harness.Runtime.Subscribe(recording);
|
|
|
|
LiveEntityRecord record =
|
|
harness.Entities.RegisterAndMaterializeProjection(Spawn(
|
|
Harness.TargetGuid,
|
|
instance: 4,
|
|
cell: 0x12340001u));
|
|
|
|
Assert.True(harness.EntityObjects.Entities.IsCurrent(record.Canonical));
|
|
Assert.Contains(
|
|
recording.Entries,
|
|
entry => entry.Kind == RuntimeTraceKind.Entity
|
|
&& entry.PrimaryObjectId == Harness.TargetGuid);
|
|
Assert.Equal(1, harness.EntityObjects.Events.SubscriberCount);
|
|
Assert.Equal(0, harness.EntityObjects.Events.DispatchFailureCount);
|
|
|
|
first.Dispose();
|
|
second.Dispose();
|
|
Assert.Equal(0, harness.EntityObjects.Events.SubscriberCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void AdapterBorrowsExactJ4ViewsAndCheckpointState()
|
|
{
|
|
using var harness = new Harness();
|
|
harness.Character.Options.Replace(0x04000000u, 0x00948700u);
|
|
harness.Character.MovementSkills.Update(200, 175);
|
|
harness.Character.Spellbook.OnSpellLearned(42u);
|
|
harness.InventoryState.Shortcuts.Load(
|
|
[
|
|
new ShortcutEntry(1, 0x80000001u, 0u),
|
|
]);
|
|
harness.Communication.Friends.Apply(new FriendsUpdate(
|
|
FriendsUpdateType.Full,
|
|
[
|
|
new FriendEntry(
|
|
0x50000003u,
|
|
"Borrowed",
|
|
true,
|
|
false,
|
|
[],
|
|
[]),
|
|
]));
|
|
|
|
Assert.Same(harness.Character.View, harness.Runtime.Character);
|
|
Assert.Same(
|
|
harness.InventoryState.View,
|
|
harness.Runtime.InventoryState);
|
|
Assert.Same(
|
|
harness.Communication.SocialView,
|
|
harness.Runtime.Social);
|
|
|
|
RuntimeStateCheckpoint checkpoint =
|
|
harness.Runtime.CaptureCheckpoint();
|
|
Assert.Equal(1, checkpoint.Character.LearnedSpellCount);
|
|
Assert.True(checkpoint.Character.MovementSkills.IsComplete);
|
|
Assert.Equal(1, checkpoint.InventoryState.ShortcutCount);
|
|
Assert.Equal(1, checkpoint.Social.FriendCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void J4StateCommandsAreGenerationGatedAndReachCurrentTransport()
|
|
{
|
|
using var harness = new Harness();
|
|
var trace = new RuntimeTraceRecorder();
|
|
using IDisposable subscription = harness.Runtime.Subscribe(trace);
|
|
_ = harness.Runtime.Session.Start(harness.Runtime.Generation);
|
|
RuntimeGenerationToken generation = harness.Runtime.Generation;
|
|
IGameRuntimeCommands commands = harness.Runtime;
|
|
|
|
RuntimeCommandResult shortcut = commands.InventoryState.AddShortcut(
|
|
generation,
|
|
new RuntimeShortcutCommand(2, 0x80000001u, 0u));
|
|
RuntimeCommandResult favorite = commands.Spellbook.AddFavorite(
|
|
generation,
|
|
tabIndex: 0,
|
|
position: 0,
|
|
spellId: 42u);
|
|
RuntimeCommandResult filter = commands.Spellbook.SetFilter(
|
|
generation,
|
|
0x3FFEu);
|
|
RuntimeCommandResult desired = commands.Spellbook.SetDesiredComponent(
|
|
generation,
|
|
0x68000001u,
|
|
10u);
|
|
RuntimeCommandResult advancement = commands.Character.Advance(
|
|
generation,
|
|
new RuntimeAdvancementCommand(
|
|
RuntimeAdvancementKind.Skill,
|
|
StatId: 6u,
|
|
Cost: 500u));
|
|
RuntimeCommandResult options = commands.Character.SetOptions1(
|
|
generation,
|
|
0x50C4A54Au);
|
|
RuntimeCommandResult friend = commands.Social.Execute(
|
|
generation,
|
|
new RuntimeFriendCommand(
|
|
RuntimeFriendCommandKind.Add,
|
|
Name: "Runtime Friend"));
|
|
RuntimeCommandResult squelch = commands.Social.Execute(
|
|
generation,
|
|
new RuntimeSquelchCommand(
|
|
RuntimeSquelchScope.Global,
|
|
Add: true,
|
|
MessageType: 3u));
|
|
|
|
Assert.All(
|
|
new[]
|
|
{
|
|
shortcut,
|
|
favorite,
|
|
filter,
|
|
desired,
|
|
advancement,
|
|
options,
|
|
friend,
|
|
squelch,
|
|
},
|
|
static result => Assert.True(result.Accepted));
|
|
Assert.Contains(
|
|
harness.Commands.Published,
|
|
static command => command is AddShortcutRuntimeCmd);
|
|
Assert.Contains(
|
|
harness.Commands.Published,
|
|
static command => command is AddFavoriteRuntimeCmd);
|
|
Assert.Contains(
|
|
harness.Commands.Published,
|
|
static command => command is SetSpellbookFilterRuntimeCmd);
|
|
Assert.Contains(
|
|
harness.Commands.Published,
|
|
static command => command is SetDesiredComponentRuntimeCmd);
|
|
Assert.Contains(
|
|
harness.Commands.Published,
|
|
static command => command is RaiseSkillRuntimeCmd);
|
|
Assert.Contains(
|
|
harness.Commands.Published,
|
|
static command => command is SetCharacterOptionsRuntimeCmd);
|
|
Assert.Contains(
|
|
harness.Commands.Published,
|
|
static command => command is AddFriendRuntimeCmd);
|
|
Assert.Contains(
|
|
harness.Commands.Published,
|
|
static command => command is ModifyGlobalSquelchRuntimeCmd);
|
|
Assert.Contains(
|
|
trace.Entries,
|
|
static entry => entry.Kind == RuntimeTraceKind.Command
|
|
&& (entry.Code >> 16)
|
|
== (int)RuntimeCommandDomain.InventoryState);
|
|
Assert.Contains(
|
|
trace.Entries,
|
|
static entry => entry.Kind == RuntimeTraceKind.Command
|
|
&& (entry.Code >> 16)
|
|
== (int)RuntimeCommandDomain.Spellbook);
|
|
Assert.Contains(
|
|
trace.Entries,
|
|
static entry => entry.Kind == RuntimeTraceKind.Command
|
|
&& (entry.Code >> 16)
|
|
== (int)RuntimeCommandDomain.Character);
|
|
Assert.Contains(
|
|
trace.Entries,
|
|
static entry => entry.Kind == RuntimeTraceKind.Command
|
|
&& (entry.Code >> 16)
|
|
== (int)RuntimeCommandDomain.Social);
|
|
Assert.True(harness.InventoryState.View.TryGetShortcut(
|
|
2,
|
|
out RuntimeShortcutSnapshot storedShortcut));
|
|
Assert.Equal(0x80000001u, storedShortcut.ObjectId);
|
|
Assert.True(harness.Character.View.TryGetFavorite(
|
|
0,
|
|
0,
|
|
out uint storedFavorite));
|
|
Assert.Equal(42u, storedFavorite);
|
|
Assert.Equal(0x3FFEu, harness.Character.Spellbook.SpellbookFilters);
|
|
Assert.True(harness.Character.View.TryGetDesiredComponent(
|
|
0x68000001u,
|
|
out uint storedDesired));
|
|
Assert.Equal(10u, storedDesired);
|
|
|
|
int published = harness.Commands.Published.Count;
|
|
RuntimeCommandResult stale = commands.Character.SetOptions1(
|
|
new RuntimeGenerationToken(generation.Value - 1),
|
|
0u);
|
|
Assert.Equal(RuntimeCommandStatus.StaleGeneration, stale.Status);
|
|
Assert.Equal(published, harness.Commands.Published.Count);
|
|
}
|
|
|
|
[Fact]
|
|
public void GraphicalAndNoWindowJ4CommandsProduceIdenticalCanonicalState()
|
|
{
|
|
using var directEntities = new RuntimeEntityObjectLifetime();
|
|
using var directInventory =
|
|
new RuntimeInventoryState(directEntities);
|
|
using var directCharacter = new RuntimeCharacterState();
|
|
using var harness = new Harness();
|
|
_ = harness.Runtime.Session.Start(harness.Runtime.Generation);
|
|
RuntimeGenerationToken generation = harness.Runtime.Generation;
|
|
IGameRuntimeCommands graphical = harness.Runtime;
|
|
var directOutbound = new List<string>();
|
|
var shortcut = new ShortcutEntry(2, 0x80000001u, 0u);
|
|
|
|
Assert.True(directInventory.TryAddShortcut(
|
|
shortcut,
|
|
() => directOutbound.Add("add-shortcut")));
|
|
Assert.True(directCharacter.TryAddFavorite(
|
|
0,
|
|
0,
|
|
42u,
|
|
() => directOutbound.Add("add-favorite")));
|
|
directCharacter.SetSpellbookFilter(
|
|
0x3FFEu,
|
|
() => directOutbound.Add("filter"));
|
|
Assert.True(directCharacter.TrySetDesiredComponent(
|
|
0x68000001u,
|
|
10u,
|
|
() => directOutbound.Add("desired")));
|
|
|
|
Assert.True(graphical.InventoryState.AddShortcut(
|
|
generation,
|
|
new RuntimeShortcutCommand(
|
|
shortcut.Index,
|
|
shortcut.ObjectId,
|
|
shortcut.SpellId)).Accepted);
|
|
Assert.True(graphical.Spellbook.AddFavorite(
|
|
generation,
|
|
0,
|
|
0,
|
|
42u).Accepted);
|
|
Assert.True(graphical.Spellbook.SetFilter(
|
|
generation,
|
|
0x3FFEu).Accepted);
|
|
Assert.True(graphical.Spellbook.SetDesiredComponent(
|
|
generation,
|
|
0x68000001u,
|
|
10u).Accepted);
|
|
|
|
Assert.Equal(
|
|
directInventory.View.Snapshot,
|
|
harness.InventoryState.View.Snapshot);
|
|
Assert.Equal(
|
|
directCharacter.View.Snapshot,
|
|
harness.Character.View.Snapshot);
|
|
Assert.Equal(
|
|
directInventory.Shortcuts.Items,
|
|
harness.InventoryState.Shortcuts.Items);
|
|
Assert.Equal(
|
|
directCharacter.Spellbook.GetFavorites(0),
|
|
harness.Character.Spellbook.GetFavorites(0));
|
|
Assert.Equal(
|
|
["add-shortcut", "add-favorite", "filter", "desired"],
|
|
directOutbound);
|
|
|
|
Assert.True(directInventory.TryRemoveShortcut(
|
|
2,
|
|
() => directOutbound.Add("remove-shortcut")));
|
|
Assert.True(directCharacter.TryRemoveFavorite(
|
|
0,
|
|
42u,
|
|
() => directOutbound.Add("remove-favorite")));
|
|
directCharacter.ClearDesiredComponents(
|
|
() => directOutbound.Add("clear-desired"));
|
|
Assert.True(graphical.InventoryState.RemoveShortcut(
|
|
generation,
|
|
2).Accepted);
|
|
Assert.True(graphical.Spellbook.RemoveFavorite(
|
|
generation,
|
|
0,
|
|
42u).Accepted);
|
|
Assert.True(graphical.Spellbook.ClearDesiredComponents(
|
|
generation).Accepted);
|
|
|
|
Assert.Equal(
|
|
directInventory.View.Snapshot,
|
|
harness.InventoryState.View.Snapshot);
|
|
Assert.Equal(
|
|
directCharacter.View.Snapshot,
|
|
harness.Character.View.Snapshot);
|
|
Assert.Empty(harness.InventoryState.Shortcuts.Items);
|
|
Assert.Empty(harness.Character.Spellbook.GetFavorites(0));
|
|
Assert.Empty(harness.Character.Spellbook.DesiredComponents);
|
|
}
|
|
|
|
private static ClientObject Object(WorldSession.EntitySpawn spawn) => new()
|
|
{
|
|
ObjectId = spawn.Guid,
|
|
Name = spawn.Name ?? string.Empty,
|
|
StackSize = 4,
|
|
Value = 25,
|
|
ContainerId = Harness.PlayerGuid,
|
|
ContainerSlot = 2,
|
|
};
|
|
|
|
private static WorldSession.EntityPositionUpdate Position(
|
|
uint guid,
|
|
ushort instance) =>
|
|
new(
|
|
guid,
|
|
new CreateObject.ServerPosition(
|
|
0x12350001u,
|
|
30f,
|
|
40f,
|
|
8f,
|
|
1f,
|
|
0f,
|
|
0f,
|
|
0f),
|
|
Velocity: new Vector3(1f, 2f, 3f),
|
|
PlacementId: null,
|
|
IsGrounded: true,
|
|
InstanceSequence: instance,
|
|
PositionSequence: 2,
|
|
TeleportSequence: 0,
|
|
ForcePositionSequence: 0);
|
|
|
|
private sealed class Harness : IDisposable
|
|
{
|
|
public const uint PlayerGuid = 0x50000002u;
|
|
public const uint TargetGuid = 0x70000001u;
|
|
|
|
private readonly ItemInteractionController _items;
|
|
private readonly LiveSessionController _session;
|
|
private readonly IDisposable _combatModeBinding;
|
|
private readonly GameRuntime _gameRuntime;
|
|
|
|
public Harness()
|
|
{
|
|
CombatAttackOperations = new CombatAttackOperationsSlot();
|
|
CombatModeOperations = new RuntimeCombatModeOperationsSlot();
|
|
SpellCastOperations = new RuntimeSpellCastOperationsSlot();
|
|
Options = LiveOptions();
|
|
Commands = new RecordingCommandRouting();
|
|
Transport = new TestTransport();
|
|
_gameRuntime = GameRuntimeTestFactory.Create(
|
|
CombatAttackOperations,
|
|
new RuntimeCombatTargetOperationsSlot(),
|
|
CombatModeOperations,
|
|
SpellCastOperations,
|
|
new SessionOperations(Transport),
|
|
combatTime: static () => 0d);
|
|
_session = _gameRuntime.Session;
|
|
Identity = new LocalPlayerIdentityState(
|
|
_gameRuntime.PlayerIdentity);
|
|
Entities = new LiveEntityRuntime(
|
|
new GpuWorldState(),
|
|
new NoopEntityResources(),
|
|
EntityObjects);
|
|
CombatMode = new RecordingCombatModeOperations();
|
|
_combatModeBinding =
|
|
CombatModeOperations.BindOwned(CombatMode);
|
|
MovementInput = new DispatcherMovementInputSource(MovementState);
|
|
GameplayInput = new GameplayInputFrameController(
|
|
dispatcher: null,
|
|
MovementInput,
|
|
mouseLook: null,
|
|
new NoopCombatInput());
|
|
Clock = new UpdateFrameClock(_gameRuntime.Clock);
|
|
WorldReveal = new WorldRevealCoordinator(
|
|
WorldTransit,
|
|
static (_, _) => true,
|
|
static _ => true,
|
|
static (_, _) => true,
|
|
static () => true,
|
|
static (_, _) => { },
|
|
static () => { },
|
|
static _ => false);
|
|
|
|
_items = new ItemInteractionController(
|
|
Objects,
|
|
Actions.Transactions,
|
|
Actions.Interaction,
|
|
() => PlayerGuid,
|
|
sendUse: null,
|
|
sendUseWithTarget: null,
|
|
sendWield: null,
|
|
sendDrop: null);
|
|
var query = new SelectionQuery(TargetGuid);
|
|
var selectionController = new SelectionInteractionController(
|
|
Selection,
|
|
query,
|
|
_items,
|
|
new SelectionTransport(() => _session?.IsInWorld == true),
|
|
new NoopInteractionMovement());
|
|
|
|
Host = CreateHost(_session, Commands, Identity);
|
|
Runtime = new CurrentGameRuntimeAdapter(
|
|
_gameRuntime,
|
|
Host,
|
|
Commands,
|
|
selectionController);
|
|
}
|
|
|
|
public RuntimeOptions Options { get; }
|
|
public LocalPlayerIdentityState Identity { get; }
|
|
public RuntimeEntityObjectLifetime EntityObjects =>
|
|
_gameRuntime.EntityObjects;
|
|
public RuntimeInventoryState InventoryState =>
|
|
_gameRuntime.InventoryOwner;
|
|
public RuntimeCharacterState Character =>
|
|
_gameRuntime.CharacterOwner;
|
|
public LiveEntityRuntime Entities { get; }
|
|
public ClientObjectTable Objects => EntityObjects.Objects;
|
|
public RuntimeCommunicationState Communication =>
|
|
_gameRuntime.CommunicationOwner;
|
|
public ChatLog Chat => Communication.Chat;
|
|
public CombatAttackOperationsSlot CombatAttackOperations { get; }
|
|
public RuntimeCombatModeOperationsSlot CombatModeOperations { get; }
|
|
public RuntimeSpellCastOperationsSlot SpellCastOperations { get; }
|
|
public RuntimeActionState Actions => _gameRuntime.ActionOwner;
|
|
public SelectionState Selection => Actions.Selection;
|
|
public RuntimeLocalPlayerMovementState MovementState =>
|
|
_gameRuntime.MovementOwner;
|
|
public RuntimeWorldEnvironmentState Environment =>
|
|
_gameRuntime.EnvironmentOwner;
|
|
public DispatcherMovementInputSource MovementInput { get; }
|
|
public GameplayInputFrameController GameplayInput { get; }
|
|
public RecordingCombatModeOperations CombatMode { get; }
|
|
public UpdateFrameClock Clock { get; }
|
|
public RuntimeWorldTransitState WorldTransit =>
|
|
_gameRuntime.TransitOwner;
|
|
public WorldRevealCoordinator WorldReveal { get; }
|
|
public RecordingCommandRouting Commands { get; }
|
|
public TestTransport Transport { get; }
|
|
public LiveSessionHost Host { get; }
|
|
public CurrentGameRuntimeAdapter Runtime { get; }
|
|
|
|
public void Dispose()
|
|
{
|
|
Runtime.Dispose();
|
|
_items.Dispose();
|
|
_combatModeBinding.Dispose();
|
|
Entities.Clear();
|
|
WorldReveal.ResetSession();
|
|
_gameRuntime.Dispose();
|
|
}
|
|
}
|
|
|
|
private static void PrepareCombatAndMagic(Harness harness)
|
|
{
|
|
harness.Actions.Combat.SetCombatMode(AcDream.Core.Combat.CombatMode.Melee);
|
|
harness.Actions.CombatAttack.SetDesiredPower(0f);
|
|
const string csv =
|
|
"Spell ID,Name,Flags [Hex],IsUntargetted,TargetMask [Hex]\n"
|
|
+ "1,Runtime Test Spell,0x0,true,0x0";
|
|
harness.Character.InstallSpellMetadata(
|
|
SpellTable.LoadFromReader(new System.IO.StringReader(csv)));
|
|
harness.Character.Spellbook.OnSpellLearned(1u);
|
|
}
|
|
|
|
private sealed class RecordingAttackOperations
|
|
: IRuntimeCombatAttackOperations
|
|
{
|
|
public List<string> Trace { get; } = [];
|
|
public bool IsDualWield => false;
|
|
public bool PlayerReadyForAttack => true;
|
|
public bool AutoRepeatAttack => false;
|
|
|
|
public bool CanStartAttack() => true;
|
|
|
|
public void PrepareAttackRequest() => Trace.Add("prepare");
|
|
|
|
public bool SendAttack(
|
|
AcDream.Core.Combat.AttackHeight height,
|
|
float power)
|
|
{
|
|
Trace.Add($"attack:{height}:{power}");
|
|
return true;
|
|
}
|
|
|
|
public void SendCancelAttack() => Trace.Add("cancel");
|
|
}
|
|
|
|
private sealed class RecordingSpellOperations
|
|
: IRuntimeSpellCastOperations
|
|
{
|
|
public uint LocalPlayerId => Harness.PlayerGuid;
|
|
public bool CanSend => true;
|
|
public List<string> Trace { get; } = [];
|
|
|
|
public bool HasRequiredComponents(uint spellId) => true;
|
|
|
|
public bool IsTargetCompatible(
|
|
uint targetId,
|
|
SpellMetadata spell,
|
|
bool showMessage) => true;
|
|
|
|
public void StopCompletely() => Trace.Add("stop");
|
|
public void SendUntargeted(uint spellId) =>
|
|
Trace.Add($"untargeted:{spellId}");
|
|
public void SendTargeted(uint targetId, uint spellId) =>
|
|
Trace.Add($"targeted:{targetId}:{spellId}");
|
|
public void DisplayMessage(string message) =>
|
|
Trace.Add($"message:{message}");
|
|
public void IncrementBusy() => Trace.Add("busy");
|
|
}
|
|
|
|
private static LiveSessionHost CreateHost(
|
|
LiveSessionController controller,
|
|
RecordingCommandRouting commands,
|
|
LocalPlayerIdentityState identity)
|
|
{
|
|
Action noop = static () => { };
|
|
var reset = new LiveSessionResetBindings
|
|
{
|
|
MouseCapture = noop,
|
|
PlayerPresentation = noop,
|
|
TeleportPresentation = noop,
|
|
SessionDialogs = noop,
|
|
SettingsCharacterContext = noop,
|
|
EquippedChildren = noop,
|
|
InteractionPresentation = noop,
|
|
SelectionPresentation = noop,
|
|
ParticleVisibility = noop,
|
|
InboundEventFifo = noop,
|
|
LiveLiveness = noop,
|
|
RuntimeGeneration = _ => { },
|
|
SessionIdentityPresentation = _ => { },
|
|
RemoteTeleport = noop,
|
|
NetworkEffects = noop,
|
|
AnimationHookFrames = noop,
|
|
LivePresentation = noop,
|
|
RemoteMovementDiagnostics = noop,
|
|
};
|
|
return new LiveSessionHost(
|
|
controller,
|
|
new LiveSessionHostBindings(
|
|
new LiveSessionRoutingFactories(
|
|
_ => new NoopEventRouting(),
|
|
_ => commands),
|
|
LiveSessionResetManifest.Create(reset).Execute,
|
|
new LiveSessionSelectionBindings(
|
|
id => identity.ServerGuid = id,
|
|
_ => { },
|
|
_ => { },
|
|
_ => { },
|
|
_ => { },
|
|
() => { }),
|
|
new LiveSessionEnteredWorldBindings(
|
|
_ => { },
|
|
() => { },
|
|
() => { },
|
|
_ => { },
|
|
() => { }),
|
|
(_, _, _) => { },
|
|
() => { }),
|
|
new LiveSessionConnectOptions(
|
|
true,
|
|
"127.0.0.1",
|
|
9000,
|
|
"user",
|
|
"password"));
|
|
}
|
|
|
|
private static RuntimeOptions LiveOptions()
|
|
{
|
|
var environment = new Dictionary<string, string?>
|
|
{
|
|
["ACDREAM_LIVE"] = "1",
|
|
["ACDREAM_TEST_HOST"] = "127.0.0.1",
|
|
["ACDREAM_TEST_PORT"] = "9000",
|
|
["ACDREAM_TEST_USER"] = "user",
|
|
["ACDREAM_TEST_PASS"] = "password",
|
|
};
|
|
return RuntimeOptions.Parse("dat", environment.GetValueOrDefault);
|
|
}
|
|
|
|
private static WorldSession.EntitySpawn Spawn(
|
|
uint guid,
|
|
ushort instance,
|
|
uint cell)
|
|
{
|
|
var position = new CreateObject.ServerPosition(
|
|
cell,
|
|
10f,
|
|
10f,
|
|
5f,
|
|
1f,
|
|
0f,
|
|
0f,
|
|
0f);
|
|
var timestamps = new PhysicsTimestamps(
|
|
Position: 1,
|
|
Movement: 1,
|
|
State: 1,
|
|
Vector: 1,
|
|
Teleport: 0,
|
|
ServerControlledMove: 1,
|
|
ForcePosition: 0,
|
|
ObjDesc: 1,
|
|
Instance: instance);
|
|
var physics = new PhysicsSpawnData(
|
|
RawState: (uint)PhysicsStateFlags.ReportCollisions,
|
|
Position: position,
|
|
Movement: null,
|
|
AnimationFrame: null,
|
|
SetupTableId: 0x02000001u,
|
|
MotionTableId: 0x09000001u,
|
|
SoundTableId: null,
|
|
PhysicsScriptTableId: null,
|
|
Parent: null,
|
|
Children: null,
|
|
Scale: null,
|
|
Friction: null,
|
|
Elasticity: null,
|
|
Translucency: null,
|
|
Velocity: null,
|
|
Acceleration: null,
|
|
AngularVelocity: null,
|
|
DefaultScriptType: null,
|
|
DefaultScriptIntensity: null,
|
|
Timestamps: timestamps);
|
|
return new WorldSession.EntitySpawn(
|
|
guid,
|
|
position,
|
|
0x02000001u,
|
|
Array.Empty<CreateObject.AnimPartChange>(),
|
|
Array.Empty<CreateObject.TextureChange>(),
|
|
Array.Empty<CreateObject.SubPaletteSwap>(),
|
|
null,
|
|
null,
|
|
"runtime fixture",
|
|
null,
|
|
null,
|
|
0x09000001u,
|
|
PhysicsState: (uint)PhysicsStateFlags.ReportCollisions,
|
|
InstanceSequence: instance,
|
|
MovementSequence: 1,
|
|
ServerControlSequence: 1,
|
|
PositionSequence: 1,
|
|
Physics: physics);
|
|
}
|
|
|
|
private sealed class SessionOperations(TestTransport transport)
|
|
: ILiveSessionOperations
|
|
{
|
|
public IPEndPoint ResolveEndpoint(string host, int port) =>
|
|
new(IPAddress.Loopback, port);
|
|
|
|
public WorldSession CreateSession(IPEndPoint endpoint) =>
|
|
new(endpoint, transport);
|
|
|
|
public void Connect(WorldSession session, string user, string password)
|
|
{
|
|
}
|
|
|
|
public CharacterList.Parsed GetCharacters(WorldSession session) =>
|
|
new(
|
|
0u,
|
|
[
|
|
new CharacterList.Character(
|
|
0x50000001u,
|
|
"Unavailable",
|
|
30u),
|
|
new CharacterList.Character(
|
|
Harness.PlayerGuid,
|
|
"Runtime",
|
|
0u),
|
|
],
|
|
[],
|
|
11,
|
|
"Runtime",
|
|
true,
|
|
true);
|
|
|
|
public void EnterWorld(WorldSession session, int activeCharacterIndex)
|
|
{
|
|
}
|
|
|
|
public void Tick(WorldSession session)
|
|
{
|
|
}
|
|
|
|
public void DisposeSession(WorldSession session) => session.Dispose();
|
|
}
|
|
|
|
internal sealed class TestTransport : IWorldSessionTransport
|
|
{
|
|
public List<byte[]> Sent { get; } = [];
|
|
|
|
public void Send(ReadOnlySpan<byte> datagram)
|
|
{
|
|
Sent.Add(datagram.ToArray());
|
|
}
|
|
|
|
public void Send(IPEndPoint remote, ReadOnlySpan<byte> datagram)
|
|
{
|
|
Sent.Add(datagram.ToArray());
|
|
}
|
|
|
|
public int Receive(
|
|
Span<byte> destination,
|
|
TimeSpan timeout,
|
|
out IPEndPoint? from)
|
|
{
|
|
from = null;
|
|
return -1;
|
|
}
|
|
|
|
public ValueTask<NetReceiveResult> ReceiveAsync(
|
|
Memory<byte> destination,
|
|
CancellationToken cancellationToken) =>
|
|
throw new OperationCanceledException(cancellationToken);
|
|
|
|
public void Dispose()
|
|
{
|
|
}
|
|
}
|
|
|
|
private sealed class NoopEventRouting : ILiveSessionEventRouting
|
|
{
|
|
public void Attach()
|
|
{
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
}
|
|
}
|
|
|
|
internal sealed class RecordingCommandRouting
|
|
: ILiveSessionCommandRouting,
|
|
ICommandBus
|
|
{
|
|
private bool _active;
|
|
|
|
public List<object> Published { get; } = [];
|
|
|
|
public void Activate() => _active = true;
|
|
|
|
public void Publish<T>(T command) where T : notnull
|
|
{
|
|
if (_active)
|
|
Published.Add(command);
|
|
}
|
|
|
|
public void Dispose() => _active = false;
|
|
}
|
|
|
|
internal sealed class RecordingCombatModeOperations
|
|
: IRuntimeCombatModeOperations
|
|
{
|
|
public List<AcDream.Core.Combat.CombatMode> Sent { get; } = [];
|
|
public bool IsInWorld => true;
|
|
public IReadOnlyList<ClientObject> GetOrderedEquipment() => [];
|
|
public void NotifyExplicitCombatModeRequest() { }
|
|
public void SendChangeCombatMode(
|
|
AcDream.Core.Combat.CombatMode mode) => Sent.Add(mode);
|
|
}
|
|
|
|
private sealed class NoopCombatInput : ICombatInputFrameController
|
|
{
|
|
public void Tick()
|
|
{
|
|
}
|
|
|
|
public void HandleMovementInput(
|
|
InputAction action,
|
|
ActivationType activation)
|
|
{
|
|
}
|
|
|
|
public bool HandleInputAction(
|
|
InputAction action,
|
|
ActivationType activation) => false;
|
|
}
|
|
|
|
private sealed class EntityObjectTrace : IRuntimeEntityObjectObserver
|
|
{
|
|
public List<string> Entries { get; } = [];
|
|
|
|
public void OnEntity(in RuntimeEntityDelta delta) =>
|
|
Entries.Add(
|
|
$"E:{delta.Stamp.Sequence}:{delta.Change}:"
|
|
+ $"{delta.Entity.Identity.ServerGuid:X8}:"
|
|
+ $"{delta.Entity.Identity.LocalEntityId}:"
|
|
+ $"{delta.Entity.Identity.Incarnation}:"
|
|
+ $"{delta.Entity.CellId:X8}:"
|
|
+ $"{delta.Entity.PhysicsState:X8}:"
|
|
+ $"{delta.Entity.Position?.ObjCellId:X8}:"
|
|
+ $"{delta.Entity.Position?.Frame.Origin.X}:"
|
|
+ $"{delta.Entity.Position?.Frame.Origin.Y}:"
|
|
+ $"{delta.Entity.Position?.Frame.Origin.Z}");
|
|
|
|
public void OnInventory(in RuntimeInventoryDelta delta) =>
|
|
Entries.Add(
|
|
$"I:{delta.Stamp.Sequence}:{delta.Change}:"
|
|
+ $"{delta.Item.ObjectId:X8}:"
|
|
+ $"{delta.Item.Incarnation}:"
|
|
+ $"{delta.Item.ContainerId:X8}:"
|
|
+ $"{delta.Item.ContainerSlot}:"
|
|
+ $"{delta.Item.StackSize}:"
|
|
+ $"{delta.Item.Value}");
|
|
}
|
|
|
|
private sealed class ThrowingEntityObserver : IRuntimeEventObserver
|
|
{
|
|
public void OnLifecycle(in RuntimeLifecycleDelta delta)
|
|
{
|
|
}
|
|
|
|
public void OnCommand(in RuntimeCommandDelta delta)
|
|
{
|
|
}
|
|
|
|
public void OnEntity(in RuntimeEntityDelta delta) =>
|
|
throw new InvalidOperationException("observer");
|
|
|
|
public void OnInventory(in RuntimeInventoryDelta delta)
|
|
{
|
|
}
|
|
|
|
public void OnChat(in RuntimeChatDelta delta)
|
|
{
|
|
}
|
|
|
|
public void OnMovement(in RuntimeMovementDelta delta)
|
|
{
|
|
}
|
|
|
|
public void OnPortal(in RuntimePortalDelta delta)
|
|
{
|
|
}
|
|
|
|
public void OnCombat(in RuntimeCombatDelta delta)
|
|
{
|
|
}
|
|
}
|
|
|
|
private sealed class SelectionQuery(uint target) : IWorldSelectionQuery
|
|
{
|
|
public uint? PickAtCursor(bool includeSelf) => target;
|
|
public uint? PickAt(float mouseX, float mouseY, bool includeSelf) => target;
|
|
public void BeginLightingPulse(uint serverGuid)
|
|
{
|
|
}
|
|
|
|
public bool TryCaptureIdentity(uint serverGuid, out uint localEntityId)
|
|
{
|
|
localEntityId = 1u;
|
|
return serverGuid == target;
|
|
}
|
|
|
|
public bool IsCurrent(uint serverGuid, uint localEntityId) =>
|
|
serverGuid == target && localEntityId == 1u;
|
|
|
|
public string Describe(uint serverGuid) => "Runtime target";
|
|
public bool IsCreature(uint serverGuid) => serverGuid == target;
|
|
public bool IsHostileMonster(uint serverGuid) => serverGuid == target;
|
|
public ClosestCombatTarget? FindClosestHostileMonster() =>
|
|
new(target, DistanceSquared: 4f);
|
|
public bool IsUseable(uint serverGuid) => serverGuid == target;
|
|
public bool IsPickupable(uint serverGuid) => false;
|
|
public bool IsWieldedByPlayer(uint serverGuid) => false;
|
|
public bool IsWieldedPositionState(uint serverGuid) => false;
|
|
|
|
public bool TryGetApproach(
|
|
uint serverGuid,
|
|
out InteractionApproach approach)
|
|
{
|
|
approach = default;
|
|
return false;
|
|
}
|
|
|
|
public Vector3? GetCombatCameraTargetPoint(uint serverGuid) => null;
|
|
}
|
|
|
|
private sealed class SelectionTransport(Func<bool> isInWorld)
|
|
: IRuntimeInteractionTransport
|
|
{
|
|
public bool IsInWorld => isInWorld();
|
|
|
|
public bool TrySendUse(uint serverGuid, out uint sequence)
|
|
{
|
|
sequence = 1u;
|
|
return IsInWorld;
|
|
}
|
|
|
|
public bool TrySendPickup(
|
|
uint itemGuid,
|
|
uint destinationContainerId,
|
|
int placement,
|
|
out uint sequence)
|
|
{
|
|
sequence = 1u;
|
|
return IsInWorld;
|
|
}
|
|
}
|
|
|
|
private sealed class NoopInteractionMovement
|
|
: IPlayerInteractionMovementSink
|
|
{
|
|
public bool BeginApproach(
|
|
InteractionApproach approach,
|
|
Action<PlayerApproachToken>? armAfterCancel = null) => false;
|
|
}
|
|
|
|
private sealed class NoopEntityResources
|
|
: ILiveEntityResourceLifecycle
|
|
{
|
|
public void Register(WorldEntity entity)
|
|
{
|
|
}
|
|
|
|
public void Unregister(WorldEntity entity)
|
|
{
|
|
}
|
|
}
|
|
}
|