refactor(runtime): unify generation reset for direct hosts

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

View file

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

View file

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

View file

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

View file

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