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,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() { }
}
}