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

@ -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;
}
}
}