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:
parent
7818494116
commit
a9a822f206
28 changed files with 2707 additions and 345 deletions
650
tests/AcDream.Runtime.Tests/NoWindowGameRuntimeHostTests.cs
Normal file
650
tests/AcDream.Runtime.Tests/NoWindowGameRuntimeHostTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
359
tests/AcDream.Runtime.Tests/RuntimeGenerationResetTests.cs
Normal file
359
tests/AcDream.Runtime.Tests/RuntimeGenerationResetTests.cs
Normal 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() { }
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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")]
|
||||
|
|
|
|||
|
|
@ -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}"),
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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}"),
|
||||
_ => { },
|
||||
|
|
|
|||
797
tests/AcDream.Runtime.Tests/Support/NoWindowGameRuntimeHost.cs
Normal file
797
tests/AcDream.Runtime.Tests/Support/NoWindowGameRuntimeHost.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue