refactor(runtime): unify generation reset for direct hosts

Move canonical per-session teardown into one retryable Runtime transaction, reduce App reset to projection acknowledgements, and prove the same GameRuntime graph through deterministic no-window lifecycle, gameplay, portal, fault, reconnect, and isolation gates.\n\nCo-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Erik 2026-07-27 00:43:26 +02:00
parent 7818494116
commit a9a822f206
28 changed files with 2707 additions and 345 deletions

View file

@ -13,6 +13,7 @@ using AcDream.Core.Selection;
using AcDream.Core.Social;
using AcDream.Core.Spells;
using AcDream.Core.World;
using AcDream.Runtime;
using DatReaderWriter.DBObjs;
namespace AcDream.App.Tests.Net;
@ -199,190 +200,107 @@ public sealed class LiveSessionResetPlanTests
}
[Fact]
public void Manifest_MutatedA_FailedDrain_RetryConvergesWithoutClearingRetainedWorld()
public void Manifest_PreservesHostOrderAndForwardsExactRetiringGeneration()
{
const uint playerA = 0x50000001u;
const uint objectA = 0x70000001u;
const uint landblock = 0x0101FFFFu;
var staticEntity = Entity(42u, 0u);
var spatial = new GpuWorldState();
spatial.AddLandblock(new LoadedLandblock(
landblock,
new LandBlock(),
new List<WorldEntity> { staticEntity }));
var live = LiveEntityRuntimeFixture.Create(spatial, new FailingOnceResources());
WorldSession.EntitySpawn spawn = Spawn(playerA, 1, 1, 0x01010001u);
live.RegisterLiveEntity(spawn);
live.MaterializeLiveEntity(
playerA,
0x01010001u,
id => Entity(id, playerA));
spatial.MarkPersistent(playerA);
var objects = new ClientObjectTable();
objects.AddOrUpdate(new ClientObject { ObjectId = objectA, Name = "A" });
var spells = new Spellbook();
spells.OnSpellLearned(123u, 1f);
var combat = new CombatState();
combat.OnUpdateHealth(objectA, 0.5f);
combat.SetCombatMode(CombatMode.Melee);
var mana = new ItemManaState();
mana.OnQueryItemManaResponse(objectA, 0.5f, valid: true);
var localPlayer = new LocalPlayerState(spells);
localPlayer.OnVitalUpdate(7u, 1u, 100u, 5u, 80u);
var friends = new FriendsState();
friends.Apply(new FriendsUpdate(
FriendsUpdateType.Full,
[new FriendEntry(objectA, "A", true, false, [], [])]));
var squelch = new SquelchState();
squelch.Replace(new SquelchDatabase(
new Dictionary<string, uint> { ["A"] = 1u },
new Dictionary<uint, SquelchInfo>(),
new SquelchInfo("A", false, new HashSet<uint>())));
var turbine = new TurbineChatState();
turbine.OnChannelsReceived(1u, 2u, 3u, 4u, 5u, 6u, 7u, 8u, 9u, 10u);
var selection = new SelectionState();
selection.Select(objectA, SelectionChangeSource.World);
var external = new ExternalContainerState();
external.RequestOpen(objectA);
external.ApplyViewContents(objectA);
var chat = new ChatLog();
chat.SetLocalPlayerGuid(playerA);
chat.OnSystemMessage("retained transcript", 1u);
uint sessionIdentity = playerA;
int savedDefaultSetting = 73;
int activeSetting = savedDefaultSetting;
var calls = new Dictionary<string, int>(StringComparer.Ordinal);
var dirty = ExpectedManifestNames().ToDictionary(
static name => name,
static _ => true,
StringComparer.Ordinal);
Action Stage(string name, Action? reset = null) => () =>
{
calls[name] = calls.GetValueOrDefault(name) + 1;
reset?.Invoke();
dirty[name] = false;
};
LiveSessionResetPlan plan = LiveSessionResetManifest.Create(new()
var calls = new List<string>();
RuntimeGenerationToken observed = default;
Action Stage(string name) => () => calls.Add(name);
var plan = LiveSessionResetManifest.Create(new()
{
MouseCapture = Stage("mouse capture"),
PlayerPresentation = Stage("player presentation"),
TeleportTransit = Stage("teleport transit"),
TeleportPresentation = Stage("teleport presentation"),
SessionDialogs = Stage("session dialogs"),
ChatCommandTargets = Stage("chat command targets"),
SettingsCharacterContext = Stage(
"settings character context",
() => activeSetting = savedDefaultSetting),
SettingsCharacterContext = Stage("settings character context"),
EquippedChildren = Stage("equipped children"),
ExternalContainer = Stage("external container", () => external.Reset()),
InteractionAndSelection = Stage(
"interaction and selection",
() => selection.Reset()),
InventoryTransactions = Stage("inventory transactions"),
InteractionPresentation = Stage("interaction presentation"),
SelectionPresentation = Stage("selection presentation"),
ObjectTable = Stage("object table", objects.Clear),
Spellbook = Stage("spellbook", spells.Clear),
MagicRuntime = Stage("magic runtime"),
CombatAttack = Stage("combat attack"),
CombatState = Stage("combat state", combat.Clear),
ItemMana = Stage("item mana", mana.Clear),
LocalPlayer = Stage("local player", localPlayer.Clear),
Friends = Stage("friends", friends.Clear),
Squelch = Stage("squelch", squelch.Clear),
TurbineChat = Stage("turbine chat", turbine.Reset),
ParticleVisibility = Stage("particle visibility"),
InboundEventFifo = Stage("inbound event fifo"),
LiveLiveness = Stage("live liveness"),
LiveRuntime = Stage(
"live runtime",
() => LiveSessionEntityRuntimeReset.ClearAndRequireConvergence(live)),
RenderSceneProjection = Stage("render scene projection"),
SessionIdentity = Stage("session identity", () =>
RuntimeGeneration = generation =>
{
LiveSessionEntityRuntimeReset.RequireConvergence(live);
sessionIdentity = 0u;
chat.ResetSessionIdentity();
}),
observed = generation;
calls.Add("runtime generation");
},
SessionIdentityPresentation = _ =>
calls.Add("session identity presentation"),
RemoteTeleport = Stage("remote teleport"),
NetworkEffects = Stage("network effects"),
AnimationHookFrames = Stage("animation hook frames"),
LivePresentation = Stage("live presentation"),
RemoteMovementDiagnostics = Stage("remote movement diagnostics"),
RemoteMovementDiagnostics =
Stage("remote movement diagnostics"),
});
var retiring = new RuntimeGenerationToken(73);
plan.Execute(retiring);
Assert.Equal(ExpectedManifestNames(), plan.StageNames);
AggregateException first = Assert.Throws<AggregateException>(plan.Execute);
Assert.Equal(ExpectedManifestNames(), calls);
Assert.Equal(retiring, observed);
}
[Fact]
public void GraphicalResetHost_RetriesExactProjectionBeforeIdentityClears()
{
const uint player = 0x50000001u;
const uint landblock = 0x0101FFFFu;
using GameRuntime runtime = GameRuntimeTestFactory.Create();
runtime.PlayerIdentity.ServerGuid = player;
var spatial = new GpuWorldState();
spatial.AddLandblock(new LoadedLandblock(
landblock,
new LandBlock(),
Array.Empty<WorldEntity>()));
var live = new LiveEntityRuntime(
spatial,
new FailingOnceResources(),
runtime.EntityObjects);
live.RegisterLiveEntity(Spawn(player, 1, 1, 0x01010001u));
live.MaterializeLiveEntity(
player,
0x01010001u,
id => Entity(id, player));
var host = new GraphicalRuntimeGenerationResetHost(
live,
static () => { });
var retiring = new RuntimeGenerationToken(3);
RuntimeGenerationResetStageException first =
Assert.Throws<RuntimeGenerationResetStageException>(
() => runtime.ResetGeneration(retiring, host));
Assert.Equal(
["live runtime", "session identity"],
first.InnerExceptions
.Cast<LiveSessionResetStageException>()
.Select(static error => error.StageName));
Assert.Equal(playerA, sessionIdentity);
RuntimeGenerationResetStage.RetireEntities,
first.Stage);
Assert.Equal(player, runtime.PlayerIdentity.ServerGuid);
Assert.Equal(1, live.PendingTeardownCount);
Assert.Contains(staticEntity, spatial.Entities);
Assert.Equal(1, spatial.PersistentGuidCount);
Assert.Equal(1, live.MaterializedCount);
plan.Execute();
runtime.ResetGeneration(retiring, host);
Assert.All(ExpectedManifestNames(), name => Assert.Equal(2, calls[name]));
Assert.DoesNotContain(dirty, static pair => pair.Value);
Assert.Equal(0u, sessionIdentity);
Assert.Equal(savedDefaultSetting, activeSetting);
Assert.Equal(0u, runtime.PlayerIdentity.ServerGuid);
Assert.Equal(0, live.Count);
Assert.Equal(0, live.PendingTeardownCount);
Assert.Equal(0, live.MaterializedCount);
Assert.Equal(0, spatial.PersistentGuidCount);
Assert.Contains(staticEntity, spatial.Entities);
Assert.True(spatial.IsLoaded(landblock));
Assert.Null(objects.Get(objectA));
Assert.Equal(0, spells.LearnedCount);
Assert.Equal(CombatMode.NonCombat, combat.CurrentMode);
Assert.False(combat.HasHealth(objectA));
Assert.False(mana.HasMana(objectA));
Assert.Null(localPlayer.Get(LocalPlayerState.VitalKind.Health));
Assert.Empty(friends.Snapshot());
Assert.Same(SquelchDatabase.Empty, squelch.Snapshot());
Assert.False(turbine.Enabled);
Assert.Null(selection.SelectedObjectId);
Assert.Equal(0u, external.CurrentContainerId);
Assert.Equal(1, chat.Count);
chat.OnLocalSpeech("A", "after", playerA, isRanged: false);
Assert.Equal("A", chat.Snapshot()[1].Sender);
}
private static string[] ExpectedManifestNames() =>
[
"mouse capture",
"player presentation",
"teleport transit",
"teleport presentation",
"session dialogs",
"chat command targets",
"settings character context",
"equipped children",
"external container",
"interaction and selection",
"inventory transactions",
"interaction presentation",
"selection presentation",
"object table",
"spellbook",
"magic runtime",
"combat attack",
"combat state",
"item mana",
"local player",
"friends",
"squelch",
"turbine chat",
"particle visibility",
"inbound event fifo",
"live liveness",
"live runtime",
"render scene projection",
"session identity",
"runtime generation",
"session identity presentation",
"remote teleport",
"network effects",
"animation hook frames",

View file

@ -2,6 +2,7 @@ using System.Net;
using AcDream.App.Rendering;
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Runtime;
using AcDream.Runtime.Session;
namespace AcDream.App.Tests.Net;
@ -92,7 +93,8 @@ public sealed class LiveSessionShutdownIntegrationTests
deactivateCommands: static () => { },
detachEvents: static () => { });
public void ResetSessionState() { }
public void ResetSessionState(
RuntimeGenerationToken retiringGeneration) { }
public void ReportConnecting(string host, int port, string user) { }
public void ReportConnected() { }
public void ApplySelectedCharacter(LiveSessionCharacterSelection selection) { }

View file

@ -945,31 +945,17 @@ public sealed class CurrentGameRuntimeAdapterTests
{
MouseCapture = noop,
PlayerPresentation = noop,
TeleportTransit = noop,
TeleportPresentation = noop,
SessionDialogs = noop,
ChatCommandTargets = noop,
SettingsCharacterContext = noop,
EquippedChildren = noop,
ExternalContainer = noop,
InteractionAndSelection = noop,
InventoryTransactions = noop,
InteractionPresentation = noop,
SelectionPresentation = noop,
ObjectTable = noop,
Spellbook = noop,
MagicRuntime = noop,
CombatAttack = noop,
CombatState = noop,
ItemMana = noop,
LocalPlayer = noop,
Friends = noop,
Squelch = noop,
TurbineChat = noop,
ParticleVisibility = noop,
InboundEventFifo = noop,
LiveLiveness = noop,
LiveRuntime = noop,
RenderSceneProjection = noop,
SessionIdentity = noop,
RuntimeGeneration = _ => { },
SessionIdentityPresentation = _ => { },
RemoteTeleport = noop,
NetworkEffects = noop,
AnimationHookFrames = noop,

View file

@ -850,6 +850,9 @@ public sealed class LocalPlayerTeleportControllerTests
public void ResetSession()
{
}
public void ResetGenerationPresentation()
{
}
}
private sealed class FakePresentation : ILocalPlayerTeleportPresentation

View file

@ -0,0 +1,650 @@
using System.Numerics;
using AcDream.Core.Items;
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
using AcDream.Runtime.Entities;
using AcDream.Runtime.Tests.Support;
using AcDream.Runtime.World;
namespace AcDream.Runtime.Tests;
public sealed class NoWindowGameRuntimeHostTests
{
[Fact]
public void RootHostRunsPopulatedGenerationWithoutPresentationAssembly()
{
var host = new NoWindowGameRuntimeHost();
RuntimeSessionStartResult started = host.Start();
Assert.Equal(RuntimeSessionStartStatus.Connected, started.Status);
Assert.Equal(0x50000001u, host.Runtime.PlayerIdentity.ServerGuid);
Assert.True(host.Deliver(runtime =>
{
runtime.EntityObjects.RegisterEntity(
Spawn(0x70000001u, 1));
runtime.EntityObjects.Objects.AddOrUpdate(new ClientObject
{
ObjectId = 0x80000001u,
Name = "fixture",
ContainerId = runtime.PlayerIdentity.ServerGuid,
});
runtime.CommunicationOwner.Chat.OnSystemMessage(
"fixture",
1u);
}));
host.Advance(1d / 30d);
RuntimeStateCheckpoint populated =
host.Runtime.CaptureCheckpoint();
Assert.Equal(1, populated.EntityCount);
Assert.Equal(1, populated.InventoryObjectCount);
Assert.Equal(1, populated.ChatCount);
RuntimeTeardownAcknowledgement stopped = host.Stop();
Assert.True(stopped.IsComplete);
Assert.Equal(0u, host.Runtime.PlayerIdentity.ServerGuid);
Assert.Equal(0, host.Runtime.Entities.Count);
Assert.Equal(0, host.Runtime.Inventory.ObjectCount);
Assert.False(host.Deliver(_ =>
throw new InvalidOperationException("old route leaked")));
Assert.False(host.Execute(_ =>
throw new InvalidOperationException("old command leaked")));
Assert.Equal(1, host.ProjectionRetirementCount);
Assert.Equal(2, host.ProjectionDrainCount);
Assert.Equal(2, host.ProjectionCompletionCount);
string[] loaded = AppDomain.CurrentDomain.GetAssemblies()
.Select(static assembly =>
assembly.GetName().Name ?? string.Empty)
.ToArray();
Assert.DoesNotContain(loaded, static name =>
name.StartsWith(
"AcDream.App",
StringComparison.OrdinalIgnoreCase)
|| name.StartsWith(
"AcDream.UI.",
StringComparison.OrdinalIgnoreCase)
|| name.StartsWith(
"Silk.NET",
StringComparison.OrdinalIgnoreCase)
|| name.StartsWith(
"OpenAL",
StringComparison.OrdinalIgnoreCase)
|| name.StartsWith(
"Arch",
StringComparison.OrdinalIgnoreCase)
|| name.StartsWith(
"ImGui",
StringComparison.OrdinalIgnoreCase));
host.Dispose();
Assert.True(host.Runtime.CaptureOwnership().IsConverged);
}
[Fact]
public void ReconnectReusesRootButNotPriorGuidIncarnationOrRoutes()
{
const uint guid = 0x70000001u;
var host = new NoWindowGameRuntimeHost();
RuntimeSessionStartResult first = host.Start();
RuntimeGenerationToken firstGeneration = first.Generation;
Assert.True(host.Deliver(runtime =>
runtime.EntityObjects.RegisterEntity(Spawn(guid, 1))));
Assert.True(host.Runtime.EntityObjects.Entities.TryGetActive(
guid,
out RuntimeEntityRecord firstRecord));
RuntimeSessionStartResult second = host.Reconnect();
RuntimeGenerationToken secondGeneration = second.Generation;
Assert.Equal(RuntimeSessionStartStatus.Connected, second.Status);
Assert.NotEqual(firstGeneration, secondGeneration);
Assert.False(host.Runtime.EntityObjects.Entities.TryGetActive(
guid,
out _));
Assert.True(host.Deliver(runtime =>
runtime.EntityObjects.RegisterEntity(Spawn(guid, 2))));
Assert.True(host.Runtime.EntityObjects.Entities.TryGetActive(
guid,
out RuntimeEntityRecord secondRecord));
Assert.NotSame(firstRecord, secondRecord);
Assert.Equal((ushort)2, secondRecord.Incarnation);
Assert.Equal(0, host.Runtime.EntityObjects.Entities
.PendingTeardownCount);
Assert.Contains(
$"events-:{firstGeneration.Value}",
host.LifecycleTrace);
Assert.Contains(
$"commands-:{firstGeneration.Value}",
host.LifecycleTrace);
host.Dispose();
Assert.True(host.Runtime.CaptureOwnership().IsConverged);
}
[Fact]
public void DeterministicLifecycleUsesOneRootAcrossGameplayPortalAndReconnect()
{
const uint localGuid = 0x50000001u;
const uint reusedGuid = 0x70000001u;
const uint usableGuid = 0x70000002u;
const uint containerGuid = 0x80000001u;
const uint itemGuid = 0x80000002u;
const string passwordMarker = "credential-must-not-appear";
var host = new NoWindowGameRuntimeHost(
password: passwordMarker);
RuntimeStateCheckpoint empty = host.CaptureCheckpoint();
Assert.Equal(RuntimeLifecycleState.Constructed, empty.Lifecycle);
Assert.Equal(0, empty.EntityCount);
Assert.Equal(0, empty.InventoryObjectCount);
Assert.Equal(0, empty.ChatCount);
RuntimeSessionStartResult first = host.Start();
RuntimeGenerationToken firstGeneration = first.Generation;
Assert.Equal(RuntimeSessionStartStatus.Connected, first.Status);
Assert.True(host.Deliver(firstGeneration, runtime =>
{
runtime.EntityObjects.RegisterEntity(Spawn(localGuid, 1));
runtime.EntityObjects.RegisterEntity(Spawn(reusedGuid, 1));
runtime.EntityObjects.RegisterEntity(Spawn(usableGuid, 1));
runtime.EntityObjects.Objects.AddOrUpdate(new ClientObject
{
ObjectId = containerGuid,
Name = "fixture pack",
ContainerId = localGuid,
});
runtime.EntityObjects.Objects.AddContainer(new Container
{
ObjectId = containerGuid,
Capacity = 24,
});
runtime.EntityObjects.Objects.AddOrUpdate(new ClientObject
{
ObjectId = itemGuid,
Name = "fixture item",
ContainerId = localGuid,
StackSize = 3,
StackSizeMax = 10,
});
Assert.True(runtime.EntityObjects.Objects.ApplyServerMove(
itemGuid,
containerGuid,
newWielderId: 0u,
newSlot: 0));
runtime.CommunicationOwner.Chat.OnSystemMessage(
"fixture system",
1u);
runtime.CommunicationOwner.Chat.OnLocalSpeech(
"Runtime",
"fixture speech",
localGuid,
isRanged: false);
runtime.CharacterOwner.LocalPlayer.OnVitalUpdate(
vitalId: 1u,
ranks: 100u,
start: 100u,
xp: 50u,
current: 95u);
runtime.EnvironmentOwner.Initialize(
new RuntimeWorldEnvironmentDefinition(
originOffsetTicks: 0d,
sourceTickSize: 1d,
lightTickSize: 1d,
dayGroups: null));
runtime.EnvironmentOwner.SynchronizeFromServer(1234d);
}));
Assert.True(host.Move());
Assert.True(host.Use(usableGuid));
Assert.True(host.Attack());
Assert.True(host.CastFixtureSpell());
Assert.True(host.AdvanceProjectile());
Assert.True(host.CompletePortal());
host.Advance(1d / 30d);
RuntimeStateCheckpoint populated = host.CaptureCheckpoint();
Assert.Equal(RuntimeLifecycleState.InWorld, populated.Lifecycle);
Assert.Equal(4, populated.EntityCount);
Assert.Equal(2, populated.InventoryObjectCount);
Assert.Equal(1, populated.InventoryContainerCount);
Assert.Equal(2, populated.ChatCount);
Assert.Equal(1, populated.Character.LearnedSpellCount);
Assert.True(populated.Movement.AutoRunActive);
Assert.True(populated.Portal.Completed);
Assert.True(populated.Portal.WorldViewportObserved);
Assert.Equal(0, populated.TransitOwnership.HostProjectionCount);
Assert.Equal(
[
"movement:run",
$"use:{usableGuid:X8}",
"attack:prepare",
"attack:Medium:0.5",
"cast:stop",
"cast:1",
"cast:busy",
"projectile:ack",
],
host.GameplayTrace);
RuntimeTraceEntry[] firstTrace = host.Trace.Entries
.Where(entry =>
entry.Stamp.Generation == firstGeneration)
.ToArray();
Assert.Contains(
firstTrace,
static entry => entry.Kind == RuntimeTraceKind.Lifecycle);
Assert.Contains(
firstTrace,
static entry => entry.Kind == RuntimeTraceKind.Inventory
&& entry.Code == (int)RuntimeInventoryChange.Moved);
Assert.Contains(
firstTrace,
static entry => entry.Kind == RuntimeTraceKind.Movement);
Assert.Contains(
firstTrace,
static entry => entry.Kind == RuntimeTraceKind.Command
&& entry.Code >> 16
== (int)RuntimeCommandDomain.Magic);
Assert.Equal(
5,
firstTrace.Count(static entry =>
entry.Kind == RuntimeTraceKind.Portal));
AssertStrictlyOrdered(firstTrace);
Assert.True(host.Runtime.EntityObjects.Entities.TryGetActive(
reusedGuid,
out RuntimeEntityRecord firstRecord));
RuntimeSessionStartResult second = host.Reconnect();
RuntimeGenerationToken secondGeneration = second.Generation;
Assert.Equal(RuntimeSessionStartStatus.Connected, second.Status);
Assert.NotEqual(firstGeneration, secondGeneration);
Assert.False(host.Deliver(
firstGeneration,
_ => throw new InvalidOperationException(
"retired inbound route ran")));
Assert.False(host.Execute(
firstGeneration,
_ => throw new InvalidOperationException(
"retired command route ran")));
RuntimeStateCheckpoint reset = host.Runtime.CaptureCheckpoint();
Assert.Equal(0, reset.EntityCount);
Assert.Equal(0, reset.InventoryObjectCount);
// Retail's end-character path resets active communication targets and
// identity but does not issue ClearChatBuffer; bounded chat history is
// therefore retained by this reusable root.
Assert.Equal(2, reset.ChatCount);
Assert.Equal(0, reset.Character.LearnedSpellCount);
Assert.Equal(0u, reset.Actions.SelectedObjectId);
Assert.Equal(RuntimePortalSnapshot.Idle, reset.Portal);
Assert.False(reset.Movement.AutoRunActive);
Assert.True(reset.EnvironmentOwnership.IsInitialized);
Assert.True(host.Deliver(secondGeneration, runtime =>
runtime.EntityObjects.RegisterEntity(
Spawn(reusedGuid, 2))));
Assert.True(host.Runtime.EntityObjects.Entities.TryGetActive(
reusedGuid,
out RuntimeEntityRecord secondRecord));
Assert.NotSame(firstRecord, secondRecord);
Assert.Equal((ushort)2, secondRecord.Incarnation);
RuntimeStateCheckpoint secondCheckpoint =
host.CaptureCheckpoint();
Assert.Equal(1, secondCheckpoint.EntityCount);
Assert.Equal(0, secondCheckpoint.InventoryObjectCount);
Assert.Equal(2, secondCheckpoint.ChatCount);
Assert.True(host.Stop().IsComplete);
AssertStrictlyOrdered(
host.Trace.Entries
.Where(entry =>
entry.Stamp.Generation == secondGeneration)
.ToArray());
Assert.DoesNotContain(
host.LifecycleTrace.Concat(host.GameplayTrace),
line => line.Contains(
passwordMarker,
StringComparison.Ordinal));
host.Dispose();
Assert.True(host.Runtime.CaptureOwnership().IsConverged);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void PendingPortalCancellationRetiresHostAcknowledgementsBeforeReconnect(
bool destinationReady)
{
var host = new NoWindowGameRuntimeHost();
Assert.Equal(
RuntimeSessionStartStatus.Connected,
host.Start().Status);
RuntimeWorldTransitState transit = host.Runtime.TransitOwner;
const uint destinationCell = 0x8A020164u;
const ushort sequence = 7;
Assert.True(transit.TryQueueTeleportStart(sequence));
Assert.True(transit.ActivateQueuedTeleport());
Assert.True(transit.OfferTeleportDestination(
new RuntimeTeleportDestination(
host.Runtime.PlayerIdentity.ServerGuid,
InstanceSequence: 1,
PositionSequence: 1,
TeleportSequence: sequence,
ForcePositionSequence: 1,
Position: new Position(
destinationCell,
Vector3.Zero,
Quaternion.Identity)),
teleportTimestampAdvanced: true));
Assert.True(transit.TryBeginPortalReveal(
sequence,
destinationCell,
out long revealGeneration));
Assert.True(transit.TryRegisterHostProjection(
revealGeneration,
destinationCell,
out RuntimeWorldHostProjectionToken projection));
Assert.True(transit.AcknowledgeHostProjection(
new RuntimeWorldHostAcknowledgement(
projection,
RuntimeWorldHostAcknowledgementStage
.ProjectionRegistered)));
if (destinationReady)
{
Assert.True(transit.AcknowledgeDestinationReadiness(
new RuntimeDestinationReadiness(
revealGeneration,
destinationCell,
IsIndoor: true,
IsUnhydratable: false,
RequiredRenderRadius: 0,
IsRenderNeighborhoodReady: true,
AreCompositeTexturesReady: true,
IsCollisionReady: true)));
}
Assert.True(transit.Cancel(revealGeneration));
DrainPendingPortal(transit, projection);
Assert.Equal(0, transit.Ownership.HostProjectionCount);
RuntimeSessionStartResult reconnected = host.Reconnect();
Assert.Equal(
RuntimeSessionStartStatus.Connected,
reconnected.Status);
Assert.Equal(RuntimePortalSnapshot.Idle, transit.Snapshot);
host.Dispose();
Assert.True(host.Runtime.CaptureOwnership().IsConverged);
}
[Fact]
public void ConcurrentRootsKeepCredentialsClockStateAndFailuresIsolated()
{
const string firstPassword = "first-password-marker";
const string secondPassword = "second-password-marker";
const uint sharedGuid = 0x70000055u;
var first = new NoWindowGameRuntimeHost(
user: "first-user",
password: firstPassword,
characterId: 0x50000011u,
characterName: "First");
var second = new NoWindowGameRuntimeHost(
user: "second-user",
password: secondPassword,
characterId: 0x50000022u,
characterName: "Second");
Assert.Equal(
RuntimeSessionStartStatus.Connected,
first.Start().Status);
Assert.Equal(
RuntimeSessionStartStatus.Connected,
second.Start().Status);
Assert.True(first.Deliver(runtime =>
{
runtime.EntityObjects.RegisterEntity(Spawn(sharedGuid, 1));
runtime.EntityObjects.Objects.AddOrUpdate(new ClientObject
{
ObjectId = 0x80000011u,
Name = "first item",
ContainerId = runtime.PlayerIdentity.ServerGuid,
});
runtime.CommunicationOwner.Chat.OnSystemMessage(
"first chat",
1u);
}));
Assert.True(second.Deliver(runtime =>
{
runtime.EntityObjects.RegisterEntity(Spawn(sharedGuid, 1));
runtime.EntityObjects.Objects.AddOrUpdate(new ClientObject
{
ObjectId = 0x80000022u,
Name = "second item",
ContainerId = runtime.PlayerIdentity.ServerGuid,
});
runtime.CommunicationOwner.Chat.OnSystemMessage(
"second chat",
1u);
}));
Assert.True(first.CastFixtureSpell());
Assert.True(second.CastFixtureSpell());
Assert.True(first.AdvanceProjectile(0x70000011u));
Assert.True(second.AdvanceProjectile(0x70000022u));
Assert.True(first.CompletePortal(0x8A020164u, sequence: 1));
Assert.True(second.CompletePortal(0x11340021u, sequence: 2));
first.Advance(0.1d);
first.Advance(0.2d);
second.Advance(0.4d);
Assert.True(first.Runtime.EntityObjects.Entities.TryGetActive(
sharedGuid,
out RuntimeEntityRecord firstRecord));
Assert.True(second.Runtime.EntityObjects.Entities.TryGetActive(
sharedGuid,
out RuntimeEntityRecord secondRecord));
Assert.NotSame(firstRecord, secondRecord);
Assert.Null(first.Runtime.EntityObjects.Objects.Get(0x80000022u));
Assert.Null(second.Runtime.EntityObjects.Objects.Get(0x80000011u));
Assert.Equal(
"first chat",
Assert.Single(
first.Runtime.CommunicationOwner.Chat.Snapshot()).Text);
Assert.Equal(
"second chat",
Assert.Single(
second.Runtime.CommunicationOwner.Chat.Snapshot()).Text);
Assert.Equal(2UL, first.Runtime.Clock.FrameNumber);
Assert.Equal(1UL, second.Runtime.Clock.FrameNumber);
Assert.NotEqual(
first.Runtime.Clock.SimulationTimeSeconds,
second.Runtime.Clock.SimulationTimeSeconds);
Assert.Equal(
0x8A020164u,
first.Runtime.TransitOwner.Snapshot.DestinationCell);
Assert.Equal(
0x11340021u,
second.Runtime.TransitOwner.Snapshot.DestinationCell);
Assert.DoesNotContain(
first.LifecycleTrace.Concat(first.GameplayTrace),
line => line.Contains(
firstPassword,
StringComparison.Ordinal)
|| line.Contains(
secondPassword,
StringComparison.Ordinal));
Assert.DoesNotContain(
second.LifecycleTrace.Concat(second.GameplayTrace),
line => line.Contains(
firstPassword,
StringComparison.Ordinal)
|| line.Contains(
secondPassword,
StringComparison.Ordinal));
first.Dispose();
second.Dispose();
Assert.True(first.Runtime.CaptureOwnership().IsConverged);
Assert.True(second.Runtime.CaptureOwnership().IsConverged);
}
[Fact]
public void ThrowingRootObserverIsContainedAndDoesNotBlockTeardown()
{
var host = new NoWindowGameRuntimeHost();
using IDisposable failure =
host.Runtime.Subscribe(new ThrowingObserver());
Assert.Equal(
RuntimeSessionStartStatus.Connected,
host.Start().Status);
Assert.True(host.Deliver(runtime =>
{
runtime.EntityObjects.RegisterEntity(
Spawn(0x70000077u, 1));
runtime.CommunicationOwner.Chat.OnSystemMessage(
"observer fixture",
1u);
}));
Assert.True(host.Move());
Assert.True(host.CompletePortal());
Assert.True(
host.Runtime.CaptureOwnership()
.Events.DispatchFailureCount >= 4);
Assert.True(host.Stop().IsComplete);
failure.Dispose();
host.Dispose();
Assert.True(host.Runtime.CaptureOwnership().IsConverged);
}
private static void AssertStrictlyOrdered(
IReadOnlyList<RuntimeTraceEntry> entries)
{
for (int index = 1; index < entries.Count; index++)
{
Assert.True(
entries[index].Stamp.Sequence
> entries[index - 1].Stamp.Sequence,
$"trace sequence {entries[index].Stamp.Sequence} "
+ $"did not follow {entries[index - 1].Stamp.Sequence}");
}
}
private static void DrainPendingPortal(
RuntimeWorldTransitState transit,
RuntimeWorldHostProjectionToken projection)
{
while (transit.TryGetHostProjection(
projection,
out RuntimeWorldHostProjectionSnapshot pending))
{
RuntimeWorldHostAcknowledgementStage stages =
pending.PendingAcknowledgements;
RuntimeWorldHostAcknowledgementStage stage =
(stages & RuntimeWorldHostAcknowledgementStage
.SimulationReleaseProjected) != 0
? RuntimeWorldHostAcknowledgementStage
.SimulationReleaseProjected
: (stages & RuntimeWorldHostAcknowledgementStage
.DestinationReservationReleased) != 0
? RuntimeWorldHostAcknowledgementStage
.DestinationReservationReleased
: RuntimeWorldHostAcknowledgementStage
.TerminalProjected;
Assert.NotEqual(
RuntimeWorldHostAcknowledgementStage.None,
stages);
Assert.True(transit.AcknowledgeHostProjection(
new RuntimeWorldHostAcknowledgement(
projection,
stage)));
}
}
private sealed class ThrowingObserver : IRuntimeEventObserver
{
public void OnLifecycle(in RuntimeLifecycleDelta delta) =>
throw new InvalidOperationException("observer");
public void OnCommand(in RuntimeCommandDelta delta) =>
throw new InvalidOperationException("observer");
public void OnEntity(in RuntimeEntityDelta delta) =>
throw new InvalidOperationException("observer");
public void OnInventory(in RuntimeInventoryDelta delta) =>
throw new InvalidOperationException("observer");
public void OnChat(in RuntimeChatDelta delta) =>
throw new InvalidOperationException("observer");
public void OnMovement(in RuntimeMovementDelta delta) =>
throw new InvalidOperationException("observer");
public void OnPortal(in RuntimePortalDelta delta) =>
throw new InvalidOperationException("observer");
}
private static WorldSession.EntitySpawn Spawn(
uint guid,
ushort incarnation)
{
var position = new CreateObject.ServerPosition(
0x01010001u,
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: incarnation);
var physics = new PhysicsSpawnData(
RawState: (uint)PhysicsStateFlags.ReportCollisions,
Position: position,
Movement: null,
AnimationFrame: null,
SetupTableId: 0x02000001u,
MotionTableId: null,
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,
guid.ToString("X8"),
null,
null,
null,
PhysicsState: physics.RawState,
InstanceSequence: incarnation,
MovementSequence: 1,
ServerControlSequence: 1,
PositionSequence: 1,
Physics: physics);
}
}

View file

@ -0,0 +1,359 @@
using AcDream.Core.Combat;
using AcDream.Core.Items;
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
using AcDream.Core.Selection;
using AcDream.Core.Spells;
using AcDream.Runtime.Entities;
using AcDream.Runtime.Gameplay;
namespace AcDream.Runtime.Tests;
public sealed class RuntimeGenerationResetTests
{
[Fact]
public void PopulatedResetConvergesEveryCanonicalOwnerAndStampsRetiringGeneration()
{
using var runtime = Create();
const uint player = 0x50000001u;
const uint creature = 0x70000001u;
const uint item = 0x80000001u;
runtime.PlayerIdentity.ServerGuid = player;
runtime.EntityObjects.RegisterEntity(Spawn(creature, 3));
runtime.EntityObjects.Objects.AddOrUpdate(new ClientObject
{
ObjectId = item,
Name = "item",
ContainerId = player,
});
runtime.InventoryOwner.ExternalContainers.RequestOpen(creature);
runtime.InventoryOwner.ExternalContainers.ApplyViewContents(creature);
runtime.InventoryOwner.ItemMana.OnQueryItemManaResponse(
item,
0.5f,
valid: true);
runtime.CharacterOwner.Spellbook.OnSpellLearned(123u, 1f);
runtime.CharacterOwner.LocalPlayer.OnVitalUpdate(
7u,
1u,
100u,
5u,
80u);
runtime.ActionOwner.Selection.Select(
creature,
SelectionChangeSource.System);
runtime.ActionOwner.Combat.SetCombatMode(CombatMode.Missile);
runtime.CommunicationOwner.Chat.SetLocalPlayerGuid(player);
runtime.CommunicationOwner.Chat.OnSystemMessage("retained", 1u);
_ = runtime.MovementOwner.Execute(
RuntimeMovementCommand.ToggleRunLock);
var observer = new RecordingObserver();
using IDisposable subscription = runtime.Subscribe(observer);
var host = new RecordingResetHost(runtime);
var retiring = new RuntimeGenerationToken(9);
runtime.ResetGeneration(retiring, host);
Assert.Single(host.Retired);
Assert.Equal(creature, host.Retired[0].ServerGuid);
Assert.Equal(1, host.DrainCalls);
Assert.Equal(1, host.CompleteCalls);
Assert.All(
observer.Entity.Concat(observer.Inventory),
stamp => Assert.Equal(retiring, stamp.Generation));
Assert.Equal(
[1UL, 2UL],
observer.Inventory
.Concat(observer.Entity)
.OrderBy(static stamp => stamp.Sequence)
.Select(static stamp => stamp.Sequence));
Assert.Equal(0u, runtime.PlayerIdentity.ServerGuid);
Assert.Equal(0, runtime.Entities.Count);
Assert.Equal(0, runtime.Inventory.ObjectCount);
Assert.Equal(0, runtime.CharacterOwner.Spellbook.LearnedCount);
Assert.Null(runtime.CharacterOwner.LocalPlayer.Get(
AcDream.Core.Player.LocalPlayerState.VitalKind.Health));
Assert.Equal(0u, runtime.Actions.Snapshot.SelectedObjectId);
Assert.Equal(CombatMode.NonCombat, runtime.Actions.Snapshot.CombatMode);
Assert.False(runtime.MovementOwner.AutoRunActive);
Assert.Equal(1, runtime.CommunicationOwner.Chat.Count);
Assert.Null(
runtime.CommunicationOwner.CommandTargets.LastIncomingTellSender);
Assert.False(runtime.GenerationReset.CaptureSnapshot().IsActive);
runtime.ResetGeneration(retiring, host);
Assert.Single(host.Retired);
Assert.Equal(1, host.DrainCalls);
Assert.Equal(1, host.CompleteCalls);
}
[Fact]
public void FailedHostDrainRetriesOnlyTheExactUnfinishedSuffix()
{
using var runtime = Create();
runtime.PlayerIdentity.ServerGuid = 0x50000001u;
runtime.EntityObjects.RegisterEntity(Spawn(0x70000001u, 1));
runtime.EntityObjects.RegisterEntity(Spawn(0x70000002u, 2));
var host = new RecordingResetHost(runtime)
{
FailDrainOnce = true,
};
var retiring = new RuntimeGenerationToken(12);
RuntimeGenerationResetStageException failure =
Assert.Throws<RuntimeGenerationResetStageException>(
() => runtime.ResetGeneration(retiring, host));
Assert.Equal(
RuntimeGenerationResetStage.DrainHostProjection,
failure.Stage);
Assert.Equal(2, host.Retired.Count);
Assert.Equal(1, host.DrainCalls);
Assert.Equal(0, host.CompleteCalls);
Assert.Equal(0, runtime.EntityObjects.Entities.PendingTeardownCount);
Assert.NotEqual(0u, runtime.PlayerIdentity.ServerGuid);
RuntimeGenerationResetSnapshot pending =
runtime.GenerationReset.CaptureSnapshot();
Assert.True(pending.IsActive);
Assert.Equal(2, pending.RetirementCursor);
runtime.ResetGeneration(retiring, host);
Assert.Equal(2, host.Retired.Count);
Assert.Equal(2, host.DrainCalls);
Assert.Equal(1, host.CompleteCalls);
Assert.Equal(0u, runtime.PlayerIdentity.ServerGuid);
Assert.False(runtime.GenerationReset.CaptureSnapshot().IsActive);
}
[Fact]
public void FailedHostCompletionKeepsOldIdentityAndDoesNotReplayDrainOrEntities()
{
using var runtime = Create();
const uint player = 0x50000001u;
runtime.PlayerIdentity.ServerGuid = player;
runtime.EntityObjects.RegisterEntity(Spawn(0x70000001u, 1));
var host = new RecordingResetHost(runtime)
{
FailCompleteOnce = true,
};
var retiring = new RuntimeGenerationToken(17);
RuntimeGenerationResetStageException failure =
Assert.Throws<RuntimeGenerationResetStageException>(
() => runtime.ResetGeneration(retiring, host));
Assert.Equal(
RuntimeGenerationResetStage.CompleteHostProjection,
failure.Stage);
Assert.Equal(player, runtime.PlayerIdentity.ServerGuid);
Assert.Equal(0, runtime.EntityObjects.Entities.Count);
Assert.Equal(0, runtime.EntityObjects.Entities.PendingTeardownCount);
Assert.Single(host.Retired);
Assert.Equal(1, host.DrainCalls);
Assert.Equal(1, host.CompleteCalls);
runtime.ResetGeneration(retiring, host);
Assert.Single(host.Retired);
Assert.Equal(1, host.DrainCalls);
Assert.Equal(2, host.CompleteCalls);
Assert.Equal(0u, runtime.PlayerIdentity.ServerGuid);
}
[Fact]
public void PendingResetRejectsHostReplacementAndReentrantReset()
{
using var runtime = Create();
runtime.PlayerIdentity.ServerGuid = 0x50000001u;
runtime.EntityObjects.RegisterEntity(Spawn(0x70000001u, 1));
var retiring = new RuntimeGenerationToken(4);
var host = new RecordingResetHost(runtime)
{
Reenter = true,
FailDrainOnce = true,
};
Assert.Throws<RuntimeGenerationResetStageException>(
() => runtime.ResetGeneration(retiring, host));
Assert.IsType<InvalidOperationException>(host.ReentrantFailure);
Assert.Throws<InvalidOperationException>(() =>
runtime.ResetGeneration(
retiring,
new RecordingResetHost(runtime)));
runtime.ResetGeneration(retiring, host);
Assert.False(runtime.GenerationReset.CaptureSnapshot().IsActive);
}
private static GameRuntime Create()
{
var operations = new Operations();
return new GameRuntime(new GameRuntimeDependencies(
operations,
operations,
operations,
operations));
}
private static WorldSession.EntitySpawn Spawn(
uint guid,
ushort incarnation)
{
var position = new CreateObject.ServerPosition(
0x01010001u,
10f,
10f,
5f,
1f,
0f,
0f,
0f);
var timestamps = new PhysicsTimestamps(
1,
1,
1,
1,
0,
1,
0,
1,
incarnation);
var physics = new PhysicsSpawnData(
RawState: (uint)PhysicsStateFlags.ReportCollisions,
Position: position,
Movement: null,
AnimationFrame: null,
SetupTableId: 0x02000001u,
MotionTableId: null,
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,
guid.ToString("X8"),
null,
null,
null,
PhysicsState: physics.RawState,
InstanceSequence: incarnation,
MovementSequence: 1,
ServerControlSequence: 1,
PositionSequence: 1,
Physics: physics);
}
private sealed class RecordingResetHost(GameRuntime runtime)
: IRuntimeGenerationResetHost
{
public List<RuntimeEntityRecord> Retired { get; } = [];
public int DrainCalls { get; private set; }
public int CompleteCalls { get; private set; }
public bool FailDrainOnce { get; set; }
public bool FailCompleteOnce { get; set; }
public bool Reenter { get; set; }
public Exception? ReentrantFailure { get; private set; }
public void RetireEntityProjection(RuntimeEntityRecord entity)
{
Retired.Add(entity);
if (!Reenter)
return;
Reenter = false;
ReentrantFailure = Record.Exception(() =>
runtime.ResetGeneration(
runtime.GenerationReset
.CaptureSnapshot()
.RetiringGeneration,
this));
}
public void DrainEntityProjectionBoundary()
{
DrainCalls++;
if (!FailDrainOnce)
return;
FailDrainOnce = false;
throw new InvalidOperationException("injected drain failure");
}
public void CompleteEntityProjectionRetirement()
{
CompleteCalls++;
Assert.NotEqual(0u, runtime.PlayerIdentity.ServerGuid);
if (!FailCompleteOnce)
return;
FailCompleteOnce = false;
throw new InvalidOperationException("injected completion failure");
}
}
private sealed class RecordingObserver : IRuntimeEventObserver
{
public List<RuntimeEventStamp> Entity { get; } = [];
public List<RuntimeEventStamp> Inventory { get; } = [];
public void OnLifecycle(in RuntimeLifecycleDelta delta) { }
public void OnCommand(in RuntimeCommandDelta delta) { }
public void OnEntity(in RuntimeEntityDelta delta) =>
Entity.Add(delta.Stamp);
public void OnInventory(in RuntimeInventoryDelta delta) =>
Inventory.Add(delta.Stamp);
public void OnChat(in RuntimeChatDelta delta) { }
public void OnMovement(in RuntimeMovementDelta delta) { }
public void OnPortal(in RuntimePortalDelta delta) { }
}
private sealed class Operations :
IRuntimeCombatAttackOperations,
IRuntimeCombatTargetOperations,
IRuntimeCombatModeOperations,
IRuntimeSpellCastOperations
{
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 => false;
public IReadOnlyList<ClientObject> GetOrderedEquipment() => [];
public void NotifyExplicitCombatModeRequest() { }
public void SendChangeCombatMode(CombatMode mode) { }
public uint LocalPlayerId => 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() { }
}
}

View file

@ -94,29 +94,28 @@ public sealed class RuntimeSimulationFixtureHostTests
public RuntimeOnlySimulationFixtureHost()
{
EntityObjects = new RuntimeEntityObjectLifetime();
Inventory = new RuntimeInventoryState(EntityObjects);
Character = new RuntimeCharacterState();
Runtime = new GameRuntime(new GameRuntimeDependencies(
this,
this,
this,
this,
CombatTime: () => _now));
Character.Spellbook.InstallMetadata(SpellMetadata());
Character.Spellbook.OnSpellLearned(1u);
Communication = new RuntimeCommunicationState();
Actions = new RuntimeActionState(
Inventory.Transactions,
Character.Spellbook,
this,
this,
this,
this,
now: () => _now);
Movement = new RuntimeLocalPlayerMovementState();
}
public RuntimeEntityObjectLifetime EntityObjects { get; }
public RuntimeInventoryState Inventory { get; }
public RuntimeCharacterState Character { get; }
public RuntimeCommunicationState Communication { get; }
public RuntimeActionState Actions { get; }
public RuntimeLocalPlayerMovementState Movement { get; }
public GameRuntime Runtime { get; }
public RuntimeEntityObjectLifetime EntityObjects =>
Runtime.EntityObjects;
public RuntimeInventoryState Inventory =>
Runtime.InventoryOwner;
public RuntimeCharacterState Character =>
Runtime.CharacterOwner;
public RuntimeCommunicationState Communication =>
Runtime.CommunicationOwner;
public RuntimeActionState Actions => Runtime.ActionOwner;
public RuntimeLocalPlayerMovementState Movement =>
Runtime.MovementOwner;
public List<string> Trace { get; } = [];
public void Move()
@ -219,13 +218,7 @@ public sealed class RuntimeSimulationFixtureHostTests
}
public RuntimeSimulationOwnershipSnapshot CaptureOwnership() =>
RuntimeSimulationOwnership.Capture(
EntityObjects,
Inventory,
Character,
Communication,
Actions,
Movement);
Runtime.CaptureOwnership().Simulation;
public bool IsInWorld => true;
public uint LocalPlayerId => 0x50000001u;
@ -297,12 +290,7 @@ public sealed class RuntimeSimulationFixtureHostTests
if (_disposed)
return;
_disposed = true;
Movement.Dispose();
Actions.Dispose();
Communication.Dispose();
Character.Dispose();
Inventory.Dispose();
EntityObjects.Dispose();
Runtime.Dispose();
}
private static SpellTable SpellMetadata()

View file

@ -157,6 +157,7 @@ public sealed class LiveSessionControllerTests
public int ActivateCount { get; private set; }
public List<TestCommandBus> CommandBuses { get; } = [];
public List<LiveSessionCharacterSelection> Selections { get; } = [];
public List<RuntimeGenerationToken> ResetGenerations { get; } = [];
public LiveSessionBinding BindSession(WorldSession session)
{
@ -200,10 +201,12 @@ public sealed class LiveSessionControllerTests
});
}
public void ResetSessionState()
public void ResetSessionState(
RuntimeGenerationToken retiringGeneration)
{
calls.Add("reset");
ResetCount++;
ResetGenerations.Add(retiringGeneration);
OnReset?.Invoke();
if (FailResetOnce)
{
@ -449,6 +452,34 @@ public sealed class LiveSessionControllerTests
Assert.Same(operations.Sessions[1], controller.CurrentSession);
}
[Fact]
public void ResetCallbacksCarryTheExactRetiringScopeGeneration()
{
var calls = new List<string>();
var operations = new TestOperations(calls);
var host = new TestHost(calls);
var controller = new LiveSessionController(operations);
LiveSessionStartResult first =
controller.Start(LiveOptions(), host);
RuntimeGenerationToken firstGeneration = controller.Generation;
LiveSessionStartResult second =
controller.Reconnect(LiveOptions(), host);
RuntimeGenerationToken secondGeneration = controller.Generation;
controller.Stop();
Assert.Equal(LiveSessionStartStatus.Connected, first.Status);
Assert.Equal(LiveSessionStartStatus.Connected, second.Status);
Assert.Equal(
[
RuntimeGenerationToken.Initial,
firstGeneration,
secondGeneration,
],
host.ResetGenerations);
Assert.NotEqual(firstGeneration, secondGeneration);
}
[Theory]
[InlineData("detach", "stop")]
[InlineData("detach", "dispose")]

View file

@ -221,7 +221,7 @@ public sealed class LiveSessionHostTests
Func<WorldSession, ILiveSessionCommandRouting> createCommands) =>
new(controller, new LiveSessionHostBindings(
Routing: new(createEvents, createCommands),
Reset: () => calls.Add("reset"),
Reset: _ => calls.Add("reset"),
Selection: new(
id => calls.Add($"player:{id}"),
id => calls.Add($"vitals:{id}"),

View file

@ -15,7 +15,7 @@ public sealed class LiveSessionLifecycleHostTests
var host = CreateHost(calls);
LiveSessionBinding binding = host.BindSession(sessionA);
host.ResetSessionState();
host.ResetSessionState(RuntimeGenerationToken.Initial);
host.ReportConnecting("host", 9000, "user");
host.ReportConnected();
var selection = new LiveSessionCharacterSelection(2, 3u, "toon", "account");
@ -67,7 +67,7 @@ public sealed class LiveSessionLifecycleHostTests
Func<WorldSession, LiveSessionBinding>? bind = null) =>
new(new LiveSessionLifecycleBindings(
Bind: bind ?? (session => CreateBinding(session, calls)),
Reset: () => calls.Add("reset"),
Reset: _ => calls.Add("reset"),
Connecting: (host, port, user) =>
calls.Add($"connecting:{host}:{port}:{user}"),
Connected: () => calls.Add("connected"),

View file

@ -19,7 +19,7 @@ public sealed class RuntimeLiveSessionNoWindowTests
new LiveSessionRoutingFactories(
_ => new EventRoute(calls),
_ => new CommandRoute(calls)),
() => calls.Add("reset"),
_ => calls.Add("reset"),
new LiveSessionSelectionBindings(
id => calls.Add($"player:{id}"),
_ => { },

View file

@ -0,0 +1,797 @@
using System.Net;
using System.Numerics;
using AcDream.Core.Combat;
using AcDream.Core.Items;
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
using AcDream.Core.Spells;
using AcDream.Runtime.Entities;
using AcDream.Runtime.Gameplay;
using AcDream.Runtime.Physics;
using AcDream.Runtime.Session;
using AcDream.Runtime.World;
namespace AcDream.Runtime.Tests.Support;
internal sealed class NoWindowGameRuntimeHost : IDisposable
{
private readonly FixtureOperations _operations;
private readonly GameplayOperations _gameplay;
private readonly ImmediateResetHost _resetHost = new();
private readonly IDisposable _hostLease;
private readonly IDisposable _traceSubscription;
private EventRoute? _events;
private CommandRoute? _commands;
private bool _disposed;
public NoWindowGameRuntimeHost(
string user = "runtime-user",
string password = "runtime-password",
uint characterId = 0x50000001u,
string characterName = "Runtime")
{
_operations = new FixtureOperations(
characterId,
characterName);
_gameplay = new GameplayOperations();
Runtime = new GameRuntime(new GameRuntimeDependencies(
_gameplay,
_gameplay,
_gameplay,
_gameplay,
SessionOperations: _operations,
CombatTime: () => _gameplay.Now));
_gameplay.Bind(Runtime);
_hostLease = Runtime.AcquireHostLease("no-window test host");
Trace = new RuntimeTraceRecorder();
_traceSubscription = Runtime.Subscribe(Trace);
Session = new LiveSessionHost(
Runtime.Session,
new LiveSessionHostBindings(
new LiveSessionRoutingFactories(
CreateEventRoute,
CreateCommandRoute),
(generation) =>
Runtime.ResetGeneration(generation, _resetHost),
new LiveSessionSelectionBindings(
id => Runtime.PlayerIdentity.ServerGuid = id,
_ => { },
Runtime.CommunicationOwner.Chat.SetLocalPlayerGuid,
_ => { },
_ => { },
Runtime.ActionOwner.Combat.Clear),
new LiveSessionEnteredWorldBindings(
_ => { },
() => { },
() => { },
_ => { },
() => { }),
(host, port, connectingUser) =>
_operations.RecordConnecting(
host,
port,
connectingUser),
_operations.RecordConnected),
new LiveSessionConnectOptions(
true,
"127.0.0.1",
9000,
user,
password));
}
public GameRuntime Runtime { get; }
public LiveSessionHost Session { get; }
public RuntimeTraceRecorder Trace { get; }
public IReadOnlyList<string> LifecycleTrace => _operations.Trace;
public IReadOnlyList<string> GameplayTrace => _gameplay.Trace;
public int ProjectionRetirementCount =>
_resetHost.ProjectionRetirementCount;
public int ProjectionDrainCount => _resetHost.ProjectionDrainCount;
public int ProjectionCompletionCount =>
_resetHost.ProjectionCompletionCount;
public RuntimeStateCheckpoint CaptureCheckpoint()
{
RuntimeStateCheckpoint checkpoint =
Runtime.CaptureCheckpoint();
Trace.AddCheckpoint(
Runtime.EntityObjects.Events.NextStamp(),
checkpoint);
return checkpoint;
}
public RuntimeSessionStartResult Start()
{
RuntimeLifecycleState previous = Runtime.Lifecycle.State;
RuntimeSessionStartResult result =
Session.Start(Runtime.Generation);
Runtime.EventSink.EmitLifecycle(
previous,
Runtime.Lifecycle.State);
return result;
}
public RuntimeSessionStartResult Reconnect()
{
RuntimeLifecycleState previous = Runtime.Lifecycle.State;
RuntimeSessionStartResult result =
Session.Reconnect(Runtime.Generation);
Runtime.EventSink.EmitLifecycle(
previous,
Runtime.Lifecycle.State);
return result;
}
public RuntimeTeardownAcknowledgement Stop()
{
RuntimeLifecycleState previous = Runtime.Lifecycle.State;
RuntimeTeardownAcknowledgement result =
Session.Stop(Runtime.Generation);
Runtime.EventSink.EmitLifecycle(
previous,
Runtime.Lifecycle.State);
return result;
}
public bool Deliver(Action<GameRuntime> delivery) =>
Deliver(Runtime.Generation, delivery);
public bool Deliver(
RuntimeGenerationToken expectedGeneration,
Action<GameRuntime> delivery)
{
ArgumentNullException.ThrowIfNull(delivery);
EventRoute? route = _events;
if (route is null
|| !route.IsActive
|| route.Generation != expectedGeneration
|| expectedGeneration != Runtime.Generation)
{
return false;
}
delivery(Runtime);
return true;
}
public bool Execute(Action<GameRuntime> command) =>
Execute(Runtime.Generation, command);
public bool Execute(
RuntimeGenerationToken expectedGeneration,
Action<GameRuntime> command)
{
ArgumentNullException.ThrowIfNull(command);
CommandRoute? route = _commands;
if (route is null
|| !route.IsActive
|| route.Generation != expectedGeneration
|| expectedGeneration != Runtime.Generation)
{
return false;
}
command(Runtime);
return true;
}
public bool Move()
{
return Execute(runtime =>
{
Require(
runtime.MovementOwner.Execute(
RuntimeMovementCommand.ToggleRunLock),
"movement command was rejected");
_gameplay.Trace.Add("movement:run");
runtime.EventSink.EmitMovement(
runtime.MovementOwner.Snapshot);
runtime.EventSink.EmitCommand(
RuntimeCommandDomain.Movement,
(int)RuntimeMovementCommand.ToggleRunLock,
RuntimeCommandStatus.Accepted);
});
}
public bool Use(uint serverGuid)
{
return Execute(runtime =>
{
ItemUseRequestReservation reservation =
runtime.ActionOwner.Transactions
.BeginUseRequestReservation();
RuntimeInteractionDispatchResult result =
runtime.ActionOwner.Transactions.TryDispatchUse(
serverGuid,
ownedByPlayer: false,
useable: true,
reservation,
_gameplay,
out _);
Require(
result == RuntimeInteractionDispatchResult.Dispatched,
$"use command was rejected: {result}");
runtime.ActionOwner.Transactions.CompleteUse(0u);
runtime.EventSink.EmitCommand(
RuntimeCommandDomain.Selection,
(int)RuntimeSelectionCommand.UseSelected,
RuntimeCommandStatus.Accepted,
serverGuid);
});
}
public bool Attack()
{
return Execute(runtime =>
{
runtime.ActionOwner.Combat.SetCombatMode(CombatMode.Melee);
runtime.ActionOwner.CombatAttack.SetDesiredPower(0.5f);
runtime.ActionOwner.CombatAttack.PressAttack(
AttackHeight.Medium);
_gameplay.Now += 0.5d;
runtime.ActionOwner.CombatAttack.ReleaseAttack();
runtime.EventSink.EmitCommand(
RuntimeCommandDomain.Combat,
(int)RuntimeCombatCommand.ToggleMode,
RuntimeCommandStatus.Accepted);
});
}
public bool CastFixtureSpell()
{
return Execute(runtime =>
{
runtime.CharacterOwner.InstallSpellMetadata(
FixtureSpellMetadata());
runtime.CharacterOwner.Spellbook.OnSpellLearned(1u);
CastRequestResult result =
runtime.ActionOwner.SpellCast.Cast(1u);
Require(
result == CastRequestResult.Sent,
$"spell command was rejected: {result}");
runtime.EventSink.EmitCommand(
RuntimeCommandDomain.Magic,
operation: 1,
RuntimeCommandStatus.Accepted,
primaryObjectId: 1u);
});
}
public bool AdvanceProjectile(
uint serverGuid = 0x70000021u,
ushort incarnation = 1)
{
return Execute(runtime =>
{
RuntimeEntityRecord record =
runtime.EntityObjects.RegisterEntity(
ProjectileSpawn(serverGuid, incarnation)).Canonical
?? throw new InvalidOperationException(
"projectile registration produced no canonical record");
PhysicsBody body =
runtime.EntityObjects.Physics.GetOrCreatePhysicsBody(
record,
static canonical =>
{
var created = new PhysicsBody
{
Position = new Vector3(10f, 20f, 5f),
Orientation = Quaternion.Identity,
InWorld = true,
TransientState =
TransientStateFlags.Active,
};
created.SnapToCell(
canonical.FullCellId,
created.Position,
created.Position);
return created;
});
_ = runtime.EntityObjects.Physics.BindProjectile(
record,
body,
new ProjectileCollisionSphere(
Vector3.Zero,
0.25f));
runtime.EntityObjects.Physics.AcknowledgeSpatialProjection(
record,
spatial: true);
var updater = new RuntimeProjectilePhysicsUpdater(
runtime.EntityObjects.Physics);
Require(
updater.TryBegin(
record,
quantum: 0.05f,
record.ObjectClockEpoch,
externalOwnerValid: null,
out RuntimeProjectilePhysicsCommit commit),
"projectile update did not begin");
_ = updater.Complete(
commit,
liveCenterX: 1,
liveCenterY: 1,
_ =>
{
_gameplay.Trace.Add("projectile:ack");
return true;
});
});
}
public bool CompletePortal(
uint destinationCell = 0x8A020164u,
ushort sequence = 1)
{
return Execute(runtime =>
{
RuntimeWorldTransitState transit = runtime.TransitOwner;
Require(
transit.TryQueueTeleportStart(sequence),
"portal start was rejected");
Require(
transit.ActivateQueuedTeleport(),
"portal activation was rejected");
Require(
transit.OfferTeleportDestination(
new RuntimeTeleportDestination(
runtime.PlayerIdentity.ServerGuid,
InstanceSequence: 1,
PositionSequence: 1,
TeleportSequence: sequence,
ForcePositionSequence: 1,
Position: new Position(
destinationCell,
Vector3.Zero,
Quaternion.Identity)),
teleportTimestampAdvanced: true),
"portal destination was rejected");
Require(
transit.TryBeginPortalReveal(
sequence,
destinationCell,
out long revealGeneration),
"portal reveal was rejected");
EmitPortal(runtime);
Require(
transit.TryRegisterHostProjection(
revealGeneration,
destinationCell,
out RuntimeWorldHostProjectionToken projection),
"portal projection registration was rejected");
Acknowledge(
transit,
projection,
RuntimeWorldHostAcknowledgementStage
.ProjectionRegistered);
bool indoor = (destinationCell & 0xFFFFu) >= 0x0100u;
Require(
transit.AcknowledgeDestinationReadiness(
new RuntimeDestinationReadiness(
revealGeneration,
destinationCell,
indoor,
IsUnhydratable: false,
RequiredRenderRadius: indoor ? 0 : 1,
IsRenderNeighborhoodReady: true,
AreCompositeTexturesReady: true,
IsCollisionReady: true)),
"portal readiness was rejected");
EmitPortal(runtime);
Require(
transit.AcknowledgePortalMaterialized(
revealGeneration,
sequence,
destinationCell),
"portal materialization was rejected");
EmitPortal(runtime);
DrainPortalAcknowledgements(transit, projection);
Require(
transit.RequireDestinationReservationRelease(
projection),
"portal reservation release was rejected");
DrainPortalAcknowledgements(transit, projection);
Require(
transit.AcknowledgeWorldViewportVisible(
revealGeneration),
"portal viewport acknowledgement was rejected");
EmitPortal(runtime);
Require(
transit.Complete(revealGeneration),
"portal completion was rejected");
EmitPortal(runtime);
DrainPortalAcknowledgements(transit, projection);
});
}
public void Advance(double deltaSeconds)
{
_ = Runtime.Clock.Advance(deltaSeconds);
Runtime.Session.Tick();
}
public void Dispose()
{
if (_disposed)
return;
if (Runtime.Session.CurrentSession is not null)
_ = Stop();
_traceSubscription.Dispose();
_hostLease.Dispose();
Runtime.Dispose();
_disposed = true;
}
private ILiveSessionEventRouting CreateEventRoute(WorldSession session)
{
var route = new EventRoute(Runtime.Generation, _operations.Trace);
_events = route;
return route;
}
private static void Acknowledge(
RuntimeWorldTransitState transit,
RuntimeWorldHostProjectionToken projection,
RuntimeWorldHostAcknowledgementStage stage) =>
Require(
transit.AcknowledgeHostProjection(
new RuntimeWorldHostAcknowledgement(
projection,
stage)),
$"portal host acknowledgement was rejected: {stage}");
private static void DrainPortalAcknowledgements(
RuntimeWorldTransitState transit,
RuntimeWorldHostProjectionToken projection)
{
while (transit.TryGetHostProjection(
projection,
out RuntimeWorldHostProjectionSnapshot pending))
{
RuntimeWorldHostAcknowledgementStage stages =
pending.PendingAcknowledgements;
if ((stages & RuntimeWorldHostAcknowledgementStage
.SimulationReleaseProjected) != 0)
{
Acknowledge(
transit,
projection,
RuntimeWorldHostAcknowledgementStage
.SimulationReleaseProjected);
continue;
}
if ((stages & RuntimeWorldHostAcknowledgementStage
.DestinationReservationReleased) != 0)
{
Acknowledge(
transit,
projection,
RuntimeWorldHostAcknowledgementStage
.DestinationReservationReleased);
continue;
}
if ((stages & RuntimeWorldHostAcknowledgementStage
.TerminalProjected) != 0)
{
Acknowledge(
transit,
projection,
RuntimeWorldHostAcknowledgementStage
.TerminalProjected);
continue;
}
break;
}
}
private static void EmitPortal(GameRuntime runtime) =>
runtime.EventSink.EmitPortal(runtime.TransitOwner.Snapshot);
private static SpellTable FixtureSpellMetadata()
{
const string header =
"Spell ID,Name,Flags [Hex],IsUntargetted,TargetMask [Hex]";
const string row = "1,Runtime Fixture,0x0,true,0x0";
return SpellTable.LoadFromReader(
new System.IO.StringReader($"{header}\n{row}"));
}
private static WorldSession.EntitySpawn ProjectileSpawn(
uint guid,
ushort incarnation)
{
var position = new CreateObject.ServerPosition(
0x01010001u,
10f,
20f,
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: incarnation);
var physics = new PhysicsSpawnData(
RawState: (uint)(
PhysicsStateFlags.ReportCollisions
| PhysicsStateFlags.Missile),
Position: position,
Movement: null,
AnimationFrame: null,
SetupTableId: 0x02000001u,
MotionTableId: null,
SoundTableId: null,
PhysicsScriptTableId: null,
Parent: null,
Children: null,
Scale: null,
Friction: null,
Elasticity: null,
Translucency: null,
Velocity: new Vector3(10f, 0f, 0f),
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 projectile",
null,
null,
null,
PhysicsState: physics.RawState,
InstanceSequence: incarnation,
MovementSequence: 1,
ServerControlSequence: 1,
PositionSequence: 1,
Physics: physics);
}
private static void Require(bool condition, string message)
{
if (!condition)
throw new InvalidOperationException(message);
}
private ILiveSessionCommandRouting CreateCommandRoute(
WorldSession session)
{
var route = new CommandRoute(Runtime.Generation, _operations.Trace);
_commands = route;
return route;
}
private sealed class ImmediateResetHost : IRuntimeGenerationResetHost
{
public int ProjectionRetirementCount { get; private set; }
public int ProjectionDrainCount { get; private set; }
public int ProjectionCompletionCount { get; private set; }
public void RetireEntityProjection(RuntimeEntityRecord entity) =>
ProjectionRetirementCount++;
public void DrainEntityProjectionBoundary() =>
ProjectionDrainCount++;
public void CompleteEntityProjectionRetirement() =>
ProjectionCompletionCount++;
}
private sealed class EventRoute(
RuntimeGenerationToken generation,
List<string> trace) : ILiveSessionEventRouting
{
public RuntimeGenerationToken Generation { get; } = generation;
public bool IsActive { get; private set; }
public void Attach()
{
IsActive = true;
trace.Add($"events+:{Generation.Value}");
}
public void Dispose()
{
IsActive = false;
trace.Add($"events-:{Generation.Value}");
}
}
private sealed class CommandRoute(
RuntimeGenerationToken generation,
List<string> trace) : ILiveSessionCommandRouting
{
public RuntimeGenerationToken Generation { get; } = generation;
public bool IsActive { get; private set; }
public void Activate()
{
IsActive = true;
trace.Add($"commands+:{Generation.Value}");
}
public void Dispose()
{
IsActive = false;
trace.Add($"commands-:{Generation.Value}");
}
}
private sealed class FixtureOperations(
uint characterId,
string characterName) : ILiveSessionOperations
{
public List<string> Trace { get; } = [];
public IPEndPoint ResolveEndpoint(string host, int port)
{
Trace.Add($"resolve:{host}:{port}");
return new IPEndPoint(IPAddress.Loopback, port);
}
public WorldSession CreateSession(IPEndPoint endpoint)
{
Trace.Add("session+");
return new WorldSession(endpoint, new FixtureTransport());
}
public void Connect(
WorldSession session,
string user,
string password) =>
Trace.Add($"connect:{user}");
public CharacterList.Parsed GetCharacters(WorldSession session)
{
Trace.Add("characters");
return new CharacterList.Parsed(
0u,
[new CharacterList.Character(
characterId,
characterName,
0u)],
[],
11,
"NoWindow",
true,
true);
}
public void EnterWorld(
WorldSession session,
int activeCharacterIndex) =>
Trace.Add($"enter:{activeCharacterIndex}");
public void Tick(WorldSession session) => Trace.Add("tick");
public void DisposeSession(WorldSession session)
{
Trace.Add("session-");
session.Dispose();
}
public void RecordConnecting(
string host,
int port,
string user) =>
Trace.Add($"connecting:{host}:{port}:{user}");
public void RecordConnected() => Trace.Add("connected");
}
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) =>
throw new OperationCanceledException(cancellationToken);
public void Dispose() { }
}
private sealed class GameplayOperations :
IRuntimeInteractionTransport,
IRuntimeCombatAttackOperations,
IRuntimeCombatTargetOperations,
IRuntimeCombatModeOperations,
IRuntimeSpellCastOperations
{
private GameRuntime? _runtime;
private uint _sequence;
public double Now { get; set; } = 10d;
public List<string> Trace { get; } = [];
public void Bind(GameRuntime runtime) =>
_runtime = runtime
?? throw new ArgumentNullException(nameof(runtime));
public bool CanStartAttack() => IsInWorld;
public void PrepareAttackRequest() =>
Trace.Add("attack:prepare");
public bool SendAttack(AttackHeight height, float power)
{
Trace.Add($"attack:{height}:{power:0.0}");
return true;
}
public void SendCancelAttack() { }
public bool IsDualWield => false;
public bool PlayerReadyForAttack => true;
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) =>
Trace.Add($"combat:{mode}");
public uint LocalPlayerId =>
_runtime?.PlayerIdentity.ServerGuid ?? 0u;
public bool CanSend => IsInWorld;
public bool HasRequiredComponents(uint spellId) => true;
public bool IsTargetCompatible(
uint targetId,
SpellMetadata spell,
bool showMessage) => true;
public void StopCompletely() =>
Trace.Add("cast:stop");
public void SendUntargeted(uint spellId) =>
Trace.Add($"cast:{spellId}");
public void SendTargeted(uint targetId, uint spellId) =>
Trace.Add($"cast:{targetId}:{spellId}");
public void DisplayMessage(string message) =>
Trace.Add($"message:{message}");
public void IncrementBusy()
{
_runtime?.InventoryOwner.Transactions.IncrementBusyCount();
Trace.Add("cast:busy");
}
public bool TrySendUse(uint serverGuid, out uint sequence)
{
sequence = ++_sequence;
Trace.Add($"use:{serverGuid:X8}");
return true;
}
public bool TrySendPickup(
uint itemGuid,
uint destinationContainerId,
int placement,
out uint sequence)
{
sequence = ++_sequence;
Trace.Add(
$"pickup:{itemGuid:X8}:{destinationContainerId:X8}:{placement}");
return true;
}
}
}