refactor(runtime): own magic and player state

Move the coupled Spellbook and LocalPlayerState into one Runtime-owned character graph, route content, live-session, retained UI, reset, and shutdown through that exact owner, and delete the duplicate desired-component snapshot from inventory state. Preserve synchronous retail update and reset ordering while adding independent-instance and retryable-failure coverage.

Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-26 08:39:02 +02:00
parent d362172620
commit d02a12ceac
16 changed files with 418 additions and 96 deletions

View file

@ -12,6 +12,7 @@ using AcDream.Core.Physics;
using AcDream.Core.Rendering; using AcDream.Core.Rendering;
using AcDream.Core.Spells; using AcDream.Core.Spells;
using AcDream.Core.Vfx; using AcDream.Core.Vfx;
using AcDream.Runtime.Gameplay;
using DatReaderWriter; using DatReaderWriter;
using Silk.NET.Input; using Silk.NET.Input;
using Silk.NET.OpenGL; using Silk.NET.OpenGL;
@ -48,7 +49,7 @@ internal sealed record ContentEffectsAudioDependencies(
ResidencyBudgetOptions ResidencyBudgets, ResidencyBudgetOptions ResidencyBudgets,
PhysicsDataCache PhysicsDataCache, PhysicsDataCache PhysicsDataCache,
bool DumpMotionEnabled, bool DumpMotionEnabled,
Spellbook SpellBook, RuntimeCharacterState Character,
AnimationHookRouter HookRouter, AnimationHookRouter HookRouter,
EntityEffectPoseRegistry EffectPoses, EntityEffectPoseRegistry EffectPoses,
DeferredEntityEffectAdvanceSource EntityEffectAdvance, DeferredEntityEffectAdvanceSource EntityEffectAdvance,
@ -85,7 +86,9 @@ internal interface IContentEffectsAudioCompositionFactory
IDatReaderWriter dats, IDatReaderWriter dats,
Action<string> diagnostic); Action<string> diagnostic);
MagicCatalog LoadMagicCatalog(IDatReaderWriter dats); MagicCatalog LoadMagicCatalog(IDatReaderWriter dats);
void InstallSpellMetadata(Spellbook spellBook, MagicCatalog catalog); void InstallSpellMetadata(
RuntimeCharacterState character,
MagicCatalog catalog);
int GetSpellCount(MagicCatalog catalog); int GetSpellCount(MagicCatalog catalog);
IAnimationLoader CreateAnimationLoader( IAnimationLoader CreateAnimationLoader(
IDatReaderWriter dats, IDatReaderWriter dats,
@ -143,8 +146,10 @@ internal sealed class RetailContentEffectsAudioCompositionFactory
public MagicCatalog LoadMagicCatalog(IDatReaderWriter dats) => public MagicCatalog LoadMagicCatalog(IDatReaderWriter dats) =>
MagicCatalog.Load(dats); MagicCatalog.Load(dats);
public void InstallSpellMetadata(Spellbook spellBook, MagicCatalog catalog) => public void InstallSpellMetadata(
spellBook.InstallMetadata(catalog.SpellTable); RuntimeCharacterState character,
MagicCatalog catalog) =>
character.InstallSpellMetadata(catalog.SpellTable);
public int GetSpellCount(MagicCatalog catalog) => catalog.SpellTable.Count; public int GetSpellCount(MagicCatalog catalog) => catalog.SpellTable.Count;
@ -326,7 +331,7 @@ internal sealed class ContentEffectsAudioCompositionPhase :
MagicCatalog magic = _factory.LoadMagicCatalog(dats); MagicCatalog magic = _factory.LoadMagicCatalog(dats);
_publication.PublishMagicCatalog(magic); _publication.PublishMagicCatalog(magic);
Fault(ContentEffectsAudioCompositionPoint.MagicCatalogPublished); Fault(ContentEffectsAudioCompositionPoint.MagicCatalogPublished);
_factory.InstallSpellMetadata(_dependencies.SpellBook, magic); _factory.InstallSpellMetadata(_dependencies.Character, magic);
_dependencies.Log( _dependencies.Log(
$"spells: loaded {_factory.GetSpellCount(magic)} entries from portal.dat"); $"spells: loaded {_factory.GetSpellCount(magic)} entries from portal.dat");
Fault(ContentEffectsAudioCompositionPoint.SpellMetadataInstalled); Fault(ContentEffectsAudioCompositionPoint.SpellMetadataInstalled);

View file

@ -48,9 +48,8 @@ internal sealed record InteractionRetainedUiDependencies(
SelectionState Selection, SelectionState Selection,
RuntimeInventoryState Inventory, RuntimeInventoryState Inventory,
MagicCatalog MagicCatalog, MagicCatalog MagicCatalog,
Spellbook Spellbook, RuntimeCharacterState Character,
RuntimeCommunicationState Communication, RuntimeCommunicationState Communication,
LocalPlayerState LocalPlayer,
StackSplitQuantityState StackSplitQuantity, StackSplitQuantityState StackSplitQuantity,
BufferedUiRegistry? UiRegistry, BufferedUiRegistry? UiRegistry,
LiveCombatModeCommandSlot CombatModeCommands, LiveCombatModeCommandSlot CombatModeCommands,
@ -350,7 +349,7 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
try try
{ {
VitalsVM vitals = d.ExistingVitals VitalsVM vitals = d.ExistingVitals
?? new VitalsVM(d.Combat, d.LocalPlayer); ?? new VitalsVM(d.Combat, d.Character.LocalPlayer);
UiHost host = lease.AcquireHost( UiHost host = lease.AcquireHost(
() => new UiHost( () => new UiHost(
d.Gl, d.Gl,
@ -372,7 +371,7 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
var characterSheet = new CharacterSheetProvider( var characterSheet = new CharacterSheetProvider(
d.Inventory.Objects, d.Inventory.Objects,
d.LocalPlayer, d.Character.LocalPlayer,
playerGuid: () => d.PlayerIdentity.ServerGuid, playerGuid: () => d.PlayerIdentity.ServerGuid,
activeToonName: () => d.Settings.ActiveToonKey, activeToonName: () => d.Settings.ActiveToonKey,
fallbackSheet: Studio.SampleData.SampleCharacter, fallbackSheet: Studio.SampleData.SampleCharacter,
@ -389,7 +388,7 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
MagicRuntime magic = MagicRuntime.Create( MagicRuntime magic = MagicRuntime.Create(
d.MagicCatalog, d.MagicCatalog,
d.Spellbook, d.Character.Spellbook,
d.Inventory.Objects, d.Inventory.Objects,
selectedObject: () => d.Selection.SelectedObjectId, selectedObject: () => d.Selection.SelectedObjectId,
localPlayerId: () => d.PlayerIdentity.ServerGuid, localPlayerId: () => d.PlayerIdentity.ServerGuid,
@ -421,7 +420,7 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
uint skillId = SkillId(school); uint skillId = SkillId(school);
if (skillId != 0u) if (skillId != 0u)
return d.LocalPlayer.GetSkill(skillId)?.CurrentLevel ?? 0u; return d.Character.LocalPlayer.GetSkill(skillId)?.CurrentLevel ?? 0u;
uint highest = 0u; uint highest = 0u;
foreach (MagicSchool candidate in new[] foreach (MagicSchool candidate in new[]
@ -435,7 +434,8 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
{ {
highest = Math.Max( highest = Math.Max(
highest, highest,
d.LocalPlayer.GetSkill(SkillId(candidate))?.CurrentLevel ?? 0u); d.Character.LocalPlayer.GetSkill(
SkillId(candidate))?.CurrentLevel ?? 0u);
} }
return highest; return highest;
} }
@ -527,7 +527,7 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
() => d.Settings.Gameplay, () => d.Settings.Gameplay,
d.Settings.SetCombatGameplay), d.Settings.SetCombatGameplay),
Magic: new MagicRuntimeBindings( Magic: new MagicRuntimeBindings(
d.Spellbook, d.Character.Spellbook,
magic.Casting, magic.Casting,
d.Inventory.Objects, d.Inventory.Objects,
() => d.PlayerIdentity.ServerGuid, () => d.PlayerIdentity.ServerGuid,
@ -569,10 +569,10 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
late.Selection.ResolveVividTargetInfo, late.Selection.ResolveVividTargetInfo,
late.SelectionCamera.UiSnapshot), late.SelectionCamera.UiSnapshot),
Indicators: new IndicatorRuntimeBindings( Indicators: new IndicatorRuntimeBindings(
d.Spellbook, d.Character.Spellbook,
d.Inventory.Objects, d.Inventory.Objects,
() => d.PlayerIdentity.ServerGuid, () => d.PlayerIdentity.ServerGuid,
() => d.LocalPlayer.GetAttribute( () => d.Character.LocalPlayer.GetAttribute(
LocalPlayerState.AttributeKind.Strength) LocalPlayerState.AttributeKind.Strength)
is { } strength ? (int?)strength.Current : null, is { } strength ? (int?)strength.Current : null,
() => late.Session.LinkStatus, () => late.Session.LinkStatus,
@ -614,7 +614,7 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
() => d.PlayerIdentity.ServerGuid, () => d.PlayerIdentity.ServerGuid,
iconComposer.GetIcon, iconComposer.GetIcon,
iconComposer.GetDragIcon, iconComposer.GetDragIcon,
() => d.LocalPlayer.GetAttribute( () => d.Character.LocalPlayer.GetAttribute(
LocalPlayerState.AttributeKind.Strength) LocalPlayerState.AttributeKind.Strength)
is { } strength ? (int?)strength.Current : null, is { } strength ? (int?)strength.Current : null,
guid => late.Session.CurrentSession?.SendUse(guid), guid => late.Session.CurrentSession?.SendUse(guid),

View file

@ -85,8 +85,7 @@ internal sealed record SessionPlayerDependencies(
CombatState Combat, CombatState Combat,
CombatFeedbackSlot CombatFeedback, CombatFeedbackSlot CombatFeedback,
RuntimeCommunicationState Communication, RuntimeCommunicationState Communication,
LocalPlayerState LocalPlayer, RuntimeCharacterState Character,
Spellbook Spellbook,
TransferableResourceSlot<PortalTunnelPresentation> PortalTunnelFallback, TransferableResourceSlot<PortalTunnelPresentation> PortalTunnelFallback,
Action<string> Log); Action<string> Log);
@ -802,8 +801,7 @@ internal sealed class SessionPlayerCompositionPhase
d.CharacterOptions), d.CharacterOptions),
new LiveSessionDomainRuntime( new LiveSessionDomainRuntime(
d.EntityObjects, d.EntityObjects,
d.LocalPlayer, d.Character,
d.Spellbook,
d.Combat, d.Combat,
d.Inventory, d.Inventory,
d.Communication), d.Communication),

View file

@ -42,8 +42,7 @@ internal sealed record LiveSessionPlayerRuntime(
internal sealed record LiveSessionDomainRuntime( internal sealed record LiveSessionDomainRuntime(
RuntimeEntityObjectLifetime EntityObjects, RuntimeEntityObjectLifetime EntityObjects,
LocalPlayerState LocalPlayer, RuntimeCharacterState Character,
Spellbook Spellbook,
CombatState Combat, CombatState Combat,
RuntimeInventoryState Inventory, RuntimeInventoryState Inventory,
RuntimeCommunicationState Communication); RuntimeCommunicationState Communication);
@ -170,12 +169,12 @@ internal sealed class LiveSessionRuntimeFactory
InventoryTransactions = _domain.Inventory.ResetTransactions, InventoryTransactions = _domain.Inventory.ResetTransactions,
SelectionPresentation = _world.SelectionScene.Reset, SelectionPresentation = _world.SelectionScene.Reset,
ObjectTable = _domain.EntityObjects.ClearObjects, ObjectTable = _domain.EntityObjects.ClearObjects,
Spellbook = _domain.Spellbook.Clear, Spellbook = _domain.Character.ResetSpellbook,
MagicRuntime = () => _ui.Magic?.Reset(), MagicRuntime = () => _ui.Magic?.Reset(),
CombatAttack = _interaction.CombatAttack.ResetSession, CombatAttack = _interaction.CombatAttack.ResetSession,
CombatState = _domain.Combat.Clear, CombatState = _domain.Combat.Clear,
ItemMana = _domain.Inventory.ResetItemMana, ItemMana = _domain.Inventory.ResetItemMana,
LocalPlayer = _domain.LocalPlayer.Clear, LocalPlayer = _domain.Character.ResetLocalPlayer,
Friends = _domain.Communication.ResetFriends, Friends = _domain.Communication.ResetFriends,
Squelch = _domain.Communication.ResetSquelch, Squelch = _domain.Communication.ResetSquelch,
TurbineChat = _domain.Communication.ResetNegotiatedChannels, TurbineChat = _domain.Communication.ResetNegotiatedChannels,
@ -256,7 +255,6 @@ internal sealed class LiveSessionRuntimeFactory
private LiveInventorySessionBindings CreateInventoryBindings() => new( private LiveInventorySessionBindings CreateInventoryBindings() => new(
_domain.Inventory.Objects, _domain.Inventory.Objects,
_domain.LocalPlayer,
PlayerGuid: () => _player.Identity.ServerGuid, PlayerGuid: () => _player.Identity.ServerGuid,
OnShortcuts: _domain.Inventory.Shortcuts.Replace, OnShortcuts: _domain.Inventory.Shortcuts.Replace,
OnUseDone: error => OnUseDone: error =>
@ -265,7 +263,6 @@ internal sealed class LiveSessionRuntimeFactory
_domain.Inventory.Transactions.CompleteUse(error); _domain.Inventory.Transactions.CompleteUse(error);
}, },
_domain.Inventory.ItemMana, _domain.Inventory.ItemMana,
OnDesiredComponents: _domain.Inventory.DesiredComponents.Replace,
ExternalContainers: _domain.Inventory.ExternalContainers, ExternalContainers: _domain.Inventory.ExternalContainers,
OnAppraisal: appraisal => OnAppraisal: appraisal =>
{ {
@ -281,7 +278,7 @@ internal sealed class LiveSessionRuntimeFactory
var skillCreditResolver = new LiveSkillCreditResolver(skillTable); var skillCreditResolver = new LiveSkillCreditResolver(skillTable);
return new LiveCharacterSessionBindings( return new LiveCharacterSessionBindings(
_domain.Combat, _domain.Combat,
_domain.Spellbook, _domain.Character,
ResolveSkillFormulaBonus: skillCreditResolver.Resolve, ResolveSkillFormulaBonus: skillCreditResolver.Resolve,
OnSkillsUpdated: (runSkill, jumpSkill) => OnSkillsUpdated: (runSkill, jumpSkill) =>
{ {
@ -337,7 +334,7 @@ internal sealed class LiveSessionRuntimeFactory
?? "unknown", ?? "unknown",
CurrentPosition: () => _player.Controller.Controller?.CellPosition, CurrentPosition: () => _player.Controller.Controller?.CellPosition,
LastOutsideCorpsePosition: () => LastOutsideCorpsePosition: () =>
_domain.LocalPlayer.GetPosition(0x0Eu), _domain.Character.LocalPlayer.GetPosition(0x0Eu),
ShowConfirmation: (message, completed) => ShowConfirmation: (message, completed) =>
_ui.RetailUi?.ShowConfirmation(message, completed), _ui.RetailUi?.ShowConfirmation(message, completed),
Suicide: session.SendSuicide, Suicide: session.SendSuicide,
@ -372,7 +369,6 @@ internal sealed class LiveSessionRuntimeFactory
ClearDesiredComponents: () => ClearDesiredComponents: () =>
{ {
session.SendClearDesiredComponents(); session.SendClearDesiredComponents();
_domain.Inventory.DesiredComponents.Clear();
}, },
HasOpenVendor: () => false, HasOpenVendor: () => false,
FillComponentBuyList: (_, _) => { }), FillComponentBuyList: (_, _) => { }),

View file

@ -337,14 +337,14 @@ public sealed class GameWindow :
public readonly AcDream.Core.Combat.CombatState Combat = new(); public readonly AcDream.Core.Combat.CombatState Combat = new();
private readonly RuntimeEntityObjectLifetime _runtimeEntityObjects = new(); private readonly RuntimeEntityObjectLifetime _runtimeEntityObjects = new();
private readonly RuntimeInventoryState _runtimeInventory; private readonly RuntimeInventoryState _runtimeInventory;
private readonly RuntimeCharacterState _runtimeCharacter;
public AcDream.Core.Items.ItemManaState ItemMana => public AcDream.Core.Items.ItemManaState ItemMana =>
_runtimeInventory.ItemMana; _runtimeInventory.ItemMana;
public IReadOnlyList<(uint Id, uint Amount)> DesiredComponents =>
_runtimeInventory.DesiredComponents.Items;
// Retail resolves learned IDs through portal.dat's CSpellTable. MagicCatalog // Retail resolves learned IDs through portal.dat's CSpellTable. MagicCatalog
// installs that immutable table once DatCollection opens in OnLoad. // installs that immutable table once DatCollection opens in OnLoad.
public AcDream.Core.Spells.SpellTable SpellTable => SpellBook.Metadata; public AcDream.Core.Spells.SpellTable SpellTable => SpellBook.Metadata;
public readonly AcDream.Core.Spells.Spellbook SpellBook = null!; public AcDream.Core.Spells.Spellbook SpellBook =>
_runtimeCharacter.Spellbook;
/// <summary>Persisted hotbar shortcuts from the last PlayerDescription (D.5.1 toolbar source).</summary> /// <summary>Persisted hotbar shortcuts from the last PlayerDescription (D.5.1 toolbar source).</summary>
public IReadOnlyList<AcDream.Core.Items.ShortcutEntry> Shortcuts => public IReadOnlyList<AcDream.Core.Items.ShortcutEntry> Shortcuts =>
_runtimeInventory.Shortcuts.Items; _runtimeInventory.Shortcuts.Items;
@ -352,7 +352,8 @@ public sealed class GameWindow :
// PlayerDescription so the Vitals HUD can render those bars. // PlayerDescription so the Vitals HUD can render those bars.
// Issue #6 — wired to SpellBook so GetMaxApprox folds enchantment // Issue #6 — wired to SpellBook so GetMaxApprox folds enchantment
// buffs into the max formula via Spellbook.GetVitalMod. // buffs into the max formula via Spellbook.GetVitalMod.
public readonly AcDream.Core.Player.LocalPlayerState LocalPlayer = null!; public AcDream.Core.Player.LocalPlayerState LocalPlayer =>
_runtimeCharacter.LocalPlayer;
// Phase D.2a — ImGui devtools UI overlay. Null unless ACDREAM_DEVTOOLS=1. // Phase D.2a — ImGui devtools UI overlay. Null unless ACDREAM_DEVTOOLS=1.
// See docs/plans/2026-04-24-ui-framework.md for the staged UI strategy. // See docs/plans/2026-04-24-ui-framework.md for the staged UI strategy.
@ -562,6 +563,7 @@ public sealed class GameWindow :
{ {
_options = options ?? throw new System.ArgumentNullException(nameof(options)); _options = options ?? throw new System.ArgumentNullException(nameof(options));
_runtimeInventory = new RuntimeInventoryState(_runtimeEntityObjects); _runtimeInventory = new RuntimeInventoryState(_runtimeEntityObjects);
_runtimeCharacter = new RuntimeCharacterState();
var alphaScratchBudgets = var alphaScratchBudgets =
AcDream.App.Rendering.Residency.AlphaScratchBudgetProfile.Create( AcDream.App.Rendering.Residency.AlphaScratchBudgetProfile.Create(
_options.ResidencyBudgets.AlphaScratchBytes); _options.ResidencyBudgets.AlphaScratchBytes);
@ -591,8 +593,6 @@ public sealed class GameWindow :
_uiRegistry = uiRegistry; _uiRegistry = uiRegistry;
_animatedEntities = new LiveEntityAnimationRuntimeView<LiveEntityAnimationState>( _animatedEntities = new LiveEntityAnimationRuntimeView<LiveEntityAnimationState>(
_liveEntityRuntimeSlot); _liveEntityRuntimeSlot);
SpellBook = new AcDream.Core.Spells.Spellbook();
LocalPlayer = new AcDream.Core.Player.LocalPlayerState(SpellBook);
// #184 Slice 2a: the extracted per-remote DR tick. Its stateful // #184 Slice 2a: the extracted per-remote DR tick. Its stateful
// setup/motion dependency crosses the fail-fast construction bridge; // setup/motion dependency crosses the fail-fast construction bridge;
// the server-velocity animation cycle is stateless shared policy. // the server-velocity animation cycle is stateless shared policy.
@ -1149,7 +1149,7 @@ public sealed class GameWindow :
_options.ResidencyBudgets, _options.ResidencyBudgets,
_physicsDataCache, _physicsDataCache,
_animationDiagnostics.DumpMotionEnabled, _animationDiagnostics.DumpMotionEnabled,
SpellBook, _runtimeCharacter,
_hookRouter, _hookRouter,
_effectPoses, _effectPoses,
_entityEffectAdvance, _entityEffectAdvance,
@ -1261,9 +1261,8 @@ public sealed class GameWindow :
_selection, _selection,
_runtimeInventory, _runtimeInventory,
contentEffectsAudio.MagicCatalog, contentEffectsAudio.MagicCatalog,
SpellBook, _runtimeCharacter,
_runtimeCommunication, _runtimeCommunication,
LocalPlayer,
_stackSplitQuantity, _stackSplitQuantity,
_uiRegistry, _uiRegistry,
_liveCombatModeCommands, _liveCombatModeCommands,
@ -1405,8 +1404,7 @@ public sealed class GameWindow :
Combat, Combat,
_combatFeedback, _combatFeedback,
_runtimeCommunication, _runtimeCommunication,
LocalPlayer, _runtimeCharacter,
SpellBook,
_portalTunnelFallback, _portalTunnelFallback,
Console.WriteLine), Console.WriteLine),
this).Compose( this).Compose(
@ -1582,6 +1580,7 @@ public sealed class GameWindow :
_liveEntities, _liveEntities,
_runtimeEntityObjects, _runtimeEntityObjects,
_runtimeInventory, _runtimeInventory,
_runtimeCharacter,
_runtimeCommunication, _runtimeCommunication,
_renderSceneShadow, _renderSceneShadow,
_livePresentationBindings, _livePresentationBindings,

View file

@ -85,6 +85,7 @@ internal sealed record LiveShutdownRoots(
LiveEntityRuntime? LiveEntities, LiveEntityRuntime? LiveEntities,
RuntimeEntityObjectLifetime EntityObjects, RuntimeEntityObjectLifetime EntityObjects,
RuntimeInventoryState Inventory, RuntimeInventoryState Inventory,
RuntimeCharacterState Character,
RuntimeCommunicationState Communication, RuntimeCommunicationState Communication,
RenderSceneShadowRuntime? RenderSceneShadow, RenderSceneShadowRuntime? RenderSceneShadow,
LivePresentationRuntimeBindings? PresentationBindings, LivePresentationRuntimeBindings? PresentationBindings,
@ -391,6 +392,9 @@ internal static class GameWindowShutdownManifest
]), ]),
new ResourceShutdownStage("runtime entity/object stream", new ResourceShutdownStage("runtime entity/object stream",
[ [
Hard(
"runtime character state",
live.Character.Dispose),
Hard( Hard(
"runtime inventory state", "runtime inventory state",
live.Inventory.Dispose), live.Inventory.Dispose),

View file

@ -0,0 +1,85 @@
using AcDream.Core.Player;
using AcDream.Core.Spells;
namespace AcDream.Runtime.Gameplay;
/// <summary>
/// Canonical presentation-independent owner for the local character's magic
/// and player-sheet state. The two objects form one lifetime group because
/// vital maxima read active enchantments from this exact spellbook.
/// </summary>
public sealed class RuntimeCharacterState : IDisposable
{
private bool _disposed;
public RuntimeCharacterState(SpellTable? spellTable = null)
{
Spellbook = new Spellbook(spellTable);
LocalPlayer = new LocalPlayerState(Spellbook);
}
public Spellbook Spellbook { get; }
public LocalPlayerState LocalPlayer { get; }
public bool IsDisposed => _disposed;
/// <summary>
/// Installs immutable DAT metadata without transferring its ownership to
/// Runtime. The content host may install one table after portal.dat opens.
/// </summary>
public void InstallSpellMetadata(SpellTable spellTable)
{
ObjectDisposedException.ThrowIf(_disposed, this);
Spellbook.InstallMetadata(spellTable);
}
public void ResetSpellbook()
{
ObjectDisposedException.ThrowIf(_disposed, this);
Spellbook.Clear();
}
public void ResetLocalPlayer()
{
ObjectDisposedException.ThrowIf(_disposed, this);
LocalPlayer.Clear();
}
/// <summary>
/// Clears both coupled owners while retaining every failed suffix for a
/// retry. State mutation happens before the existing synchronous
/// invalidation callbacks, so retrying is safe and convergent.
/// </summary>
public void ResetSession()
{
ObjectDisposedException.ThrowIf(_disposed, this);
List<Exception>? failures = null;
Try(Spellbook.Clear, ref failures);
Try(LocalPlayer.Clear, ref failures);
if (failures is not null)
{
throw new AggregateException(
"Runtime character state did not converge during reset.",
failures);
}
}
public void Dispose()
{
if (_disposed)
return;
ResetSession();
_disposed = true;
}
private static void Try(Action action, ref List<Exception>? failures)
{
try
{
action();
}
catch (Exception error)
{
(failures ??= []).Add(error);
}
}
}

View file

@ -20,7 +20,6 @@ public sealed class RuntimeInventoryState : IDisposable
ExternalContainers = new ExternalContainerState(); ExternalContainers = new ExternalContainerState();
ItemMana = new ItemManaState(); ItemMana = new ItemManaState();
Shortcuts = new ShortcutState(); Shortcuts = new ShortcutState();
DesiredComponents = new DesiredComponentState();
Transactions = new InventoryTransactionState(_entityObjects.Objects); Transactions = new InventoryTransactionState(_entityObjects.Objects);
} }
@ -28,7 +27,6 @@ public sealed class RuntimeInventoryState : IDisposable
public ExternalContainerState ExternalContainers { get; } public ExternalContainerState ExternalContainers { get; }
public ItemManaState ItemMana { get; } public ItemManaState ItemMana { get; }
public ShortcutState Shortcuts { get; } public ShortcutState Shortcuts { get; }
public DesiredComponentState DesiredComponents { get; }
public InventoryTransactionState Transactions { get; } public InventoryTransactionState Transactions { get; }
public bool IsDisposed => _disposed; public bool IsDisposed => _disposed;
@ -39,7 +37,6 @@ public sealed class RuntimeInventoryState : IDisposable
public void ResetPlayerSnapshots() public void ResetPlayerSnapshots()
{ {
Shortcuts.Clear(); Shortcuts.Clear();
DesiredComponents.Clear();
} }
public void Dispose() public void Dispose()
@ -50,7 +47,6 @@ public sealed class RuntimeInventoryState : IDisposable
Try(() => ExternalContainers.Reset(), ref failures); Try(() => ExternalContainers.Reset(), ref failures);
Try(ItemMana.Clear, ref failures); Try(ItemMana.Clear, ref failures);
Try(Shortcuts.Clear, ref failures); Try(Shortcuts.Clear, ref failures);
Try(DesiredComponents.Clear, ref failures);
Try(Transactions.Dispose, ref failures); Try(Transactions.Dispose, ref failures);
if (failures is not null) if (failures is not null)
throw new AggregateException( throw new AggregateException(
@ -90,25 +86,3 @@ public sealed class ShortcutState
public void Clear() => Replace(Array.Empty<ShortcutEntry>()); public void Clear() => Replace(Array.Empty<ShortcutEntry>());
} }
public sealed class DesiredComponentState
{
private IReadOnlyList<(uint Id, uint Amount)> _items =
Array.Empty<(uint Id, uint Amount)>();
private long _revision;
public IReadOnlyList<(uint Id, uint Amount)> Items =>
Volatile.Read(ref _items);
public long Revision => Interlocked.Read(ref _revision);
public void Replace(IReadOnlyList<(uint Id, uint Amount)>? items)
{
Volatile.Write(
ref _items,
items ?? Array.Empty<(uint Id, uint Amount)>());
Interlocked.Increment(ref _revision);
}
public void Clear() =>
Replace(Array.Empty<(uint Id, uint Amount)>());
}

View file

@ -6,6 +6,7 @@ using AcDream.Core.Net.Messages;
using AcDream.Core.Player; using AcDream.Core.Player;
using AcDream.Core.Social; using AcDream.Core.Social;
using AcDream.Core.Spells; using AcDream.Core.Spells;
using AcDream.Runtime.Gameplay;
namespace AcDream.Runtime.Session; namespace AcDream.Runtime.Session;
@ -29,18 +30,16 @@ public sealed record LiveEnvironmentSessionSink(
public sealed record LiveInventorySessionBindings( public sealed record LiveInventorySessionBindings(
ClientObjectTable Objects, ClientObjectTable Objects,
LocalPlayerState LocalPlayer,
Func<uint> PlayerGuid, Func<uint> PlayerGuid,
Action<IReadOnlyList<ShortcutEntry>>? OnShortcuts, Action<IReadOnlyList<ShortcutEntry>>? OnShortcuts,
Action<uint>? OnUseDone, Action<uint>? OnUseDone,
ItemManaState? ItemMana, ItemManaState? ItemMana,
Action<IReadOnlyList<(uint Id, uint Amount)>>? OnDesiredComponents,
ExternalContainerState? ExternalContainers, ExternalContainerState? ExternalContainers,
Action<AppraiseInfoParser.Parsed>? OnAppraisal = null); Action<AppraiseInfoParser.Parsed>? OnAppraisal = null);
public sealed record LiveCharacterSessionBindings( public sealed record LiveCharacterSessionBindings(
CombatState Combat, CombatState Combat,
Spellbook Spellbook, RuntimeCharacterState Character,
Func<uint, IReadOnlyDictionary<uint, uint>, uint>? ResolveSkillFormulaBonus, Func<uint, IReadOnlyDictionary<uint, uint>, uint>? ResolveSkillFormulaBonus,
Action<int, int>? OnSkillsUpdated, Action<int, int>? OnSkillsUpdated,
Action<GameEvents.CharacterConfirmationRequest>? OnConfirmationRequest, Action<GameEvents.CharacterConfirmationRequest>? OnConfirmationRequest,
@ -114,7 +113,7 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting
session, session,
inventory.Objects, inventory.Objects,
inventory.PlayerGuid, inventory.PlayerGuid,
inventory.LocalPlayer, character.Character.LocalPlayer,
IsAccepting)); IsAccepting));
ConstructionCheckpoint(); ConstructionCheckpoint();
_subscriptions.Add(CombatStateWiring.Wire( _subscriptions.Add(CombatStateWiring.Wire(
@ -149,9 +148,9 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting
session.GameEvents, session.GameEvents,
inventory.Objects, inventory.Objects,
character.Combat, character.Combat,
character.Spellbook, character.Character.Spellbook,
social.Chat, social.Chat,
inventory.LocalPlayer, character.Character.LocalPlayer,
social.TurbineChat, social.TurbineChat,
onSkillsUpdated: character.OnSkillsUpdated, onSkillsUpdated: character.OnSkillsUpdated,
resolveSkillFormulaBonus: character.ResolveSkillFormulaBonus, resolveSkillFormulaBonus: character.ResolveSkillFormulaBonus,
@ -164,7 +163,7 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting
onConfirmationDone: character.OnConfirmationDone, onConfirmationDone: character.OnConfirmationDone,
friends: social.Friends, friends: social.Friends,
squelch: social.Squelch, squelch: social.Squelch,
onDesiredComponents: inventory.OnDesiredComponents, onDesiredComponents: null,
onCharacterOptions: character.OnCharacterOptions, onCharacterOptions: character.OnCharacterOptions,
clientTime: character.ClientTime, clientTime: character.ClientTime,
externalContainers: inventory.ExternalContainers, externalContainers: inventory.ExternalContainers,
@ -202,7 +201,7 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting
h => session.TurbineChatReceived -= h, h => session.TurbineChatReceived -= h,
parsed => RouteTurbineChat(social.Chat, parsed)); parsed => RouteTurbineChat(social.Chat, parsed));
Subscribe<PrivateUpdateVital.ParsedFull>(h => session.VitalUpdated += h, h => session.VitalUpdated -= h, vital => Subscribe<PrivateUpdateVital.ParsedFull>(h => session.VitalUpdated += h, h => session.VitalUpdated -= h, vital =>
inventory.LocalPlayer.OnVitalUpdate( character.Character.LocalPlayer.OnVitalUpdate(
vital.VitalId, vital.VitalId,
vital.Ranks, vital.Ranks,
vital.Start, vital.Start,
@ -211,7 +210,9 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting
Subscribe<PrivateUpdateVital.ParsedCurrent>( Subscribe<PrivateUpdateVital.ParsedCurrent>(
h => session.VitalCurrentUpdated += h, h => session.VitalCurrentUpdated += h,
h => session.VitalCurrentUpdated -= h, h => session.VitalCurrentUpdated -= h,
vital => inventory.LocalPlayer.OnVitalCurrent(vital.VitalId, vital.Current)); vital => character.Character.LocalPlayer.OnVitalCurrent(
vital.VitalId,
vital.Current));
if (Interlocked.CompareExchange(ref _lifecycleState, 2, 1) != 1) if (Interlocked.CompareExchange(ref _lifecycleState, 2, 1) != 1)
throw new ObjectDisposedException(nameof(LiveSessionEventRouter)); throw new ObjectDisposedException(nameof(LiveSessionEventRouter));
@ -297,10 +298,9 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting
ArgumentNullException.ThrowIfNull(environment.EnvironChanged); ArgumentNullException.ThrowIfNull(environment.EnvironChanged);
ArgumentNullException.ThrowIfNull(environment.ServerTimeUpdated); ArgumentNullException.ThrowIfNull(environment.ServerTimeUpdated);
ArgumentNullException.ThrowIfNull(inventory.Objects); ArgumentNullException.ThrowIfNull(inventory.Objects);
ArgumentNullException.ThrowIfNull(inventory.LocalPlayer);
ArgumentNullException.ThrowIfNull(inventory.PlayerGuid); ArgumentNullException.ThrowIfNull(inventory.PlayerGuid);
ArgumentNullException.ThrowIfNull(character.Combat); ArgumentNullException.ThrowIfNull(character.Combat);
ArgumentNullException.ThrowIfNull(character.Spellbook); ArgumentNullException.ThrowIfNull(character.Character);
ArgumentNullException.ThrowIfNull(social.Chat); ArgumentNullException.ThrowIfNull(social.Chat);
ArgumentNullException.ThrowIfNull(social.TurbineChat); ArgumentNullException.ThrowIfNull(social.TurbineChat);
} }

View file

@ -15,6 +15,7 @@ using AcDream.Core.Physics;
using AcDream.Core.Rendering; using AcDream.Core.Rendering;
using AcDream.Core.Spells; using AcDream.Core.Spells;
using AcDream.Core.Vfx; using AcDream.Core.Vfx;
using AcDream.Runtime.Gameplay;
using DatReaderWriter.DBObjs; using DatReaderWriter.DBObjs;
using Silk.NET.Input; using Silk.NET.Input;
using Silk.NET.OpenAL; using Silk.NET.OpenAL;
@ -257,7 +258,7 @@ public sealed class ContentEffectsAudioCompositionTests
ResidencyBudgetOptions.Default, ResidencyBudgetOptions.Default,
new PhysicsDataCache(), new PhysicsDataCache(),
false, false,
new Spellbook(), new RuntimeCharacterState(),
Router, Router,
Poses, Poses,
new DeferredEntityEffectAdvanceSource(), new DeferredEntityEffectAdvanceSource(),
@ -289,7 +290,11 @@ public sealed class ContentEffectsAudioCompositionTests
throw new InvalidOperationException($"fault at {point}"); throw new InvalidOperationException($"fault at {point}");
}); });
public void Dispose() => Publication.Dispose(); public void Dispose()
{
Publication.Dispose();
Dependencies.Character.Dispose();
}
} }
private sealed class Publication : private sealed class Publication :
@ -386,7 +391,9 @@ public sealed class ContentEffectsAudioCompositionTests
return _preparedAssets; return _preparedAssets;
} }
public MagicCatalog LoadMagicCatalog(IDatReaderWriter dats) => _magic; public MagicCatalog LoadMagicCatalog(IDatReaderWriter dats) => _magic;
public void InstallSpellMetadata(Spellbook spellBook, MagicCatalog catalog) { } public void InstallSpellMetadata(
RuntimeCharacterState character,
MagicCatalog catalog) { }
public int GetSpellCount(MagicCatalog catalog) => 0; public int GetSpellCount(MagicCatalog catalog) => 0;
public IAnimationLoader CreateAnimationLoader( public IAnimationLoader CreateAnimationLoader(
IDatReaderWriter dats, IDatReaderWriter dats,

View file

@ -191,9 +191,8 @@ public sealed class InteractionRetainedUiCompositionTests
Selection: null!, Selection: null!,
Inventory: null!, Inventory: null!,
MagicCatalog: null!, MagicCatalog: null!,
Spellbook: null!, Character: null!,
Communication: null!, Communication: null!,
LocalPlayer: null!,
StackSplitQuantity: null!, StackSplitQuantity: null!,
UiRegistry: null, UiRegistry: null,
CombatModeCommands: null!, CombatModeCommands: null!,

View file

@ -0,0 +1,106 @@
namespace AcDream.App.Tests.Runtime;
public sealed class RuntimeCharacterOwnershipTests
{
[Fact]
public void ProductionConstructsOnlyTheRuntimeCharacterOwner()
{
string source = ReadSource("Rendering", "GameWindow.cs");
Assert.Contains(
"_runtimeCharacter = new RuntimeCharacterState();",
source,
StringComparison.Ordinal);
Assert.DoesNotContain(
"new AcDream.Core.Spells.Spellbook",
source,
StringComparison.Ordinal);
Assert.DoesNotContain(
"new AcDream.Core.Player.LocalPlayerState",
source,
StringComparison.Ordinal);
Assert.Contains(
"SpellBook =>\n _runtimeCharacter.Spellbook;",
Normalize(source),
StringComparison.Ordinal);
Assert.Contains(
"LocalPlayer =>\n _runtimeCharacter.LocalPlayer;",
Normalize(source),
StringComparison.Ordinal);
}
[Fact]
public void SessionRoutingAndShutdownBorrowTheExactRuntimeOwner()
{
string session = ReadSource("Net", "LiveSessionRuntimeFactory.cs");
string router = ReadRuntimeSource(
"Session",
"LiveSessionEventRouter.cs");
string content = ReadSource(
"Composition",
"ContentEffectsAudioComposition.cs");
string ui = ReadSource(
"Composition",
"InteractionRetainedUiComposition.cs");
string inventory = ReadRuntimeSource(
"Gameplay",
"RuntimeInventoryState.cs");
string shutdown = ReadSource("Rendering", "GameWindowLifetime.cs");
Assert.Contains(
"RuntimeCharacterState Character",
session,
StringComparison.Ordinal);
Assert.Contains(
"character.Character.Spellbook",
router,
StringComparison.Ordinal);
Assert.Contains(
"character.Character.LocalPlayer",
router,
StringComparison.Ordinal);
Assert.Contains(
"_factory.InstallSpellMetadata(_dependencies.Character, magic)",
content,
StringComparison.Ordinal);
Assert.Contains(
"d.Character.Spellbook",
ui,
StringComparison.Ordinal);
Assert.Contains(
"d.Character.LocalPlayer",
ui,
StringComparison.Ordinal);
Assert.DoesNotContain(
"DesiredComponentState",
inventory,
StringComparison.Ordinal);
Assert.Contains(
"\"runtime character state\"",
shutdown,
StringComparison.Ordinal);
}
private static string ReadSource(params string[] relative) =>
File.ReadAllText(Path.Combine(
[FindRepositoryRoot(), "src", "AcDream.App", .. relative]));
private static string ReadRuntimeSource(params string[] relative) =>
File.ReadAllText(Path.Combine(
[FindRepositoryRoot(), "src", "AcDream.Runtime", .. relative]));
private static string Normalize(string source) =>
source.Replace("\r\n", "\n", StringComparison.Ordinal);
private static string FindRepositoryRoot()
{
var current = new DirectoryInfo(AppContext.BaseDirectory);
while (current is not null)
{
if (File.Exists(Path.Combine(current.FullName, "AcDream.slnx")))
return current.FullName;
current = current.Parent;
}
throw new DirectoryNotFoundException("AcDream.slnx was not found.");
}
}

View file

@ -27,6 +27,10 @@ public sealed class RuntimeInventoryOwnershipTests
"new AcDream.Core.Items.ExternalContainerState", "new AcDream.Core.Items.ExternalContainerState",
source, source,
StringComparison.Ordinal); StringComparison.Ordinal);
Assert.DoesNotContain(
"DesiredComponentState",
source,
StringComparison.Ordinal);
} }
[Fact] [Fact]
@ -50,8 +54,8 @@ public sealed class RuntimeInventoryOwnershipTests
"_domain.Inventory.Transactions.CompleteUse(error)", "_domain.Inventory.Transactions.CompleteUse(error)",
session, session,
StringComparison.Ordinal); StringComparison.Ordinal);
Assert.Contains( Assert.DoesNotContain(
"OnDesiredComponents: _domain.Inventory.DesiredComponents.Replace", "_domain.Inventory.DesiredComponents",
session, session,
StringComparison.Ordinal); StringComparison.Ordinal);
AssertAppearsInOrder( AssertAppearsInOrder(

View file

@ -0,0 +1,155 @@
using AcDream.Core.Spells;
using AcDream.Runtime.Gameplay;
namespace AcDream.Runtime.Tests.Gameplay;
public sealed class RuntimeCharacterStateTests
{
[Fact]
public void OwnsOneCoupledSpellbookAndLocalPlayerGraph()
{
SpellTable table = SpellTable.Create(
[
new SpellMetadata(
SpellId: 1u,
Name: "Test",
School: "Life",
Family: 0u,
IconId: 0u,
SpellWords: "",
Duration: 60f,
ManaCost: 0,
IsDebuff: false,
IsFellowship: false,
Description: "",
SortKey: 0,
Difficulty: 0,
Flags: 0u,
Generation: 1,
IsFastWindup: false,
IsOffensive: false,
IsUntargeted: false,
Speed: 0f,
CasterEffect: 0u,
TargetEffect: 0u,
TargetMask: 0u,
SpellType: 0)
]);
using var state = new RuntimeCharacterState(table);
state.LocalPlayer.OnAttributeUpdate(
atType: 2u,
ranks: 90u,
start: 10u,
xp: 0u);
state.LocalPlayer.OnVitalUpdate(
vitalId: 7u,
ranks: 50u,
start: 50u,
xp: 0u,
current: 150u);
state.Spellbook.OnEnchantmentAdded(new ActiveEnchantmentRecord(
SpellId: 1u,
LayerId: 1u,
Duration: 60f,
CasterGuid: 2u,
Bucket: 2u,
StatModType: 0u,
StatModKey: EnchantmentMath.StatKey.MaxHealth,
StatModValue: 25f));
state.Spellbook.SetDesiredComponent(0x68000001u, 12u);
Assert.Equal(175u, state.LocalPlayer.GetMaxApprox(
AcDream.Core.Player.LocalPlayerState.VitalKind.Health));
Assert.Equal(12u, state.Spellbook.DesiredComponents[0x68000001u]);
}
[Fact]
public void InstallsImmutableMetadataOnceOnTheCanonicalSpellbook()
{
using var state = new RuntimeCharacterState();
SpellTable table = SpellTable.Create(Array.Empty<SpellMetadata>());
state.InstallSpellMetadata(table);
state.InstallSpellMetadata(table);
Assert.Same(table, state.Spellbook.Metadata);
Assert.Throws<InvalidOperationException>(
() => state.InstallSpellMetadata(SpellTable.Empty));
}
[Fact]
public void ResetMethodsPreserveTheOtherHalfOfTheLifetimeGroup()
{
using var state = new RuntimeCharacterState();
state.Spellbook.OnSpellLearned(7u);
state.LocalPlayer.OnVitalUpdate(7u, 1u, 9u, 0u, 10u);
state.ResetSpellbook();
Assert.False(state.Spellbook.Knows(7u));
Assert.NotNull(state.LocalPlayer.Get(
AcDream.Core.Player.LocalPlayerState.VitalKind.Health));
state.Spellbook.OnSpellLearned(8u);
state.ResetLocalPlayer();
Assert.True(state.Spellbook.Knows(8u));
Assert.Null(state.LocalPlayer.Get(
AcDream.Core.Player.LocalPlayerState.VitalKind.Health));
}
[Fact]
public void IndependentRuntimeInstancesNeverShareCharacterState()
{
using var first = new RuntimeCharacterState();
using var second = new RuntimeCharacterState();
first.Spellbook.OnSpellLearned(7u);
first.LocalPlayer.OnVitalUpdate(7u, 1u, 9u, 0u, 10u);
Assert.False(second.Spellbook.Knows(7u));
Assert.Null(second.LocalPlayer.Get(
AcDream.Core.Player.LocalPlayerState.VitalKind.Health));
}
[Fact]
public void DisposalRetriesObserverFailuresAndClearsBothOwners()
{
var state = new RuntimeCharacterState();
state.Spellbook.OnSpellLearned(7u);
state.LocalPlayer.OnVitalUpdate(7u, 1u, 9u, 0u, 10u);
bool failSpellbook = true;
bool failCharacter = true;
state.Spellbook.SpellbookChanged += () =>
{
if (failSpellbook)
{
failSpellbook = false;
throw new InvalidOperationException("spellbook");
}
};
state.LocalPlayer.CharacterChanged += () =>
{
if (failCharacter)
{
failCharacter = false;
throw new InvalidOperationException("character");
}
};
AggregateException error = Assert.Throws<AggregateException>(
state.Dispose);
Assert.Equal(2, error.InnerExceptions.Count);
Assert.False(state.IsDisposed);
Assert.False(state.Spellbook.Knows(7u));
Assert.Null(state.LocalPlayer.Get(
AcDream.Core.Player.LocalPlayerState.VitalKind.Health));
state.Dispose();
state.Dispose();
Assert.True(state.IsDisposed);
}
}

View file

@ -13,22 +13,17 @@ public sealed class RuntimeInventoryStateTests
using var inventory = new RuntimeInventoryState(entities); using var inventory = new RuntimeInventoryState(entities);
IReadOnlyList<ShortcutEntry> shortcuts = IReadOnlyList<ShortcutEntry> shortcuts =
[new ShortcutEntry(2, 0x50000001u, 0u)]; [new ShortcutEntry(2, 0x50000001u, 0u)];
IReadOnlyList<(uint Id, uint Amount)> components =
[(0x68000001u, 12u)];
inventory.ExternalContainers.RequestOpen(0x70000001u); inventory.ExternalContainers.RequestOpen(0x70000001u);
inventory.ExternalContainers.ApplyViewContents(0x70000001u); inventory.ExternalContainers.ApplyViewContents(0x70000001u);
inventory.ItemMana.OnQueryItemManaResponse(0x50000001u, 0.75f, true); inventory.ItemMana.OnQueryItemManaResponse(0x50000001u, 0.75f, true);
inventory.Shortcuts.Replace(shortcuts); inventory.Shortcuts.Replace(shortcuts);
inventory.DesiredComponents.Replace(components);
Assert.Same(entities.Objects, inventory.Objects); Assert.Same(entities.Objects, inventory.Objects);
Assert.Equal(0x70000001u, inventory.ExternalContainers.CurrentContainerId); Assert.Equal(0x70000001u, inventory.ExternalContainers.CurrentContainerId);
Assert.Equal(0.75f, inventory.ItemMana.GetManaPercent(0x50000001u)); Assert.Equal(0.75f, inventory.ItemMana.GetManaPercent(0x50000001u));
Assert.Same(shortcuts, inventory.Shortcuts.Items); Assert.Same(shortcuts, inventory.Shortcuts.Items);
Assert.Same(components, inventory.DesiredComponents.Items);
Assert.Equal(1, inventory.Shortcuts.Revision); Assert.Equal(1, inventory.Shortcuts.Revision);
Assert.Equal(1, inventory.DesiredComponents.Revision);
} }
[Fact] [Fact]
@ -40,7 +35,6 @@ public sealed class RuntimeInventoryStateTests
inventory.ExternalContainers.ApplyViewContents(0x70000001u); inventory.ExternalContainers.ApplyViewContents(0x70000001u);
inventory.ItemMana.OnQueryItemManaResponse(0x50000001u, 0.75f, true); inventory.ItemMana.OnQueryItemManaResponse(0x50000001u, 0.75f, true);
inventory.Shortcuts.Replace([new ShortcutEntry(1, 2u, 3u)]); inventory.Shortcuts.Replace([new ShortcutEntry(1, 2u, 3u)]);
inventory.DesiredComponents.Replace([(4u, 5u)]);
inventory.Transactions.IncrementBusyCount(); inventory.Transactions.IncrementBusyCount();
inventory.ResetExternalContainer(); inventory.ResetExternalContainer();
@ -56,7 +50,6 @@ public sealed class RuntimeInventoryStateTests
Assert.Equal(0, inventory.Transactions.BusyCount); Assert.Equal(0, inventory.Transactions.BusyCount);
Assert.False(inventory.ItemMana.HasMana(0x50000001u)); Assert.False(inventory.ItemMana.HasMana(0x50000001u));
Assert.Empty(inventory.Shortcuts.Items); Assert.Empty(inventory.Shortcuts.Items);
Assert.Empty(inventory.DesiredComponents.Items);
} }
[Fact] [Fact]
@ -86,7 +79,6 @@ public sealed class RuntimeInventoryStateTests
inventory.ExternalContainers.ApplyViewContents(0x70000001u); inventory.ExternalContainers.ApplyViewContents(0x70000001u);
inventory.ItemMana.OnQueryItemManaResponse(0x50000001u, 0.5f, true); inventory.ItemMana.OnQueryItemManaResponse(0x50000001u, 0.5f, true);
inventory.Shortcuts.Replace([new ShortcutEntry(1, 2u, 3u)]); inventory.Shortcuts.Replace([new ShortcutEntry(1, 2u, 3u)]);
inventory.DesiredComponents.Replace([(4u, 5u)]);
inventory.Dispose(); inventory.Dispose();
inventory.Dispose(); inventory.Dispose();
@ -96,7 +88,6 @@ public sealed class RuntimeInventoryStateTests
Assert.Equal(0u, inventory.ExternalContainers.CurrentContainerId); Assert.Equal(0u, inventory.ExternalContainers.CurrentContainerId);
Assert.False(inventory.ItemMana.HasMana(0x50000001u)); Assert.False(inventory.ItemMana.HasMana(0x50000001u));
Assert.Empty(inventory.Shortcuts.Items); Assert.Empty(inventory.Shortcuts.Items);
Assert.Empty(inventory.DesiredComponents.Items);
} }
[Fact] [Fact]

View file

@ -8,6 +8,7 @@ using AcDream.Core.Player;
using AcDream.Core.Social; using AcDream.Core.Social;
using AcDream.Core.Spells; using AcDream.Core.Spells;
using AcDream.Runtime.Session; using AcDream.Runtime.Session;
using AcDream.Runtime.Gameplay;
namespace AcDream.Runtime.Tests.Session; namespace AcDream.Runtime.Tests.Session;
@ -227,18 +228,16 @@ public sealed class LiveSessionEventRouterTests
private static LiveInventorySessionBindings NewInventoryBindings() => new( private static LiveInventorySessionBindings NewInventoryBindings() => new(
new ClientObjectTable(), new ClientObjectTable(),
new LocalPlayerState(),
PlayerGuid: () => 0x50000001u, PlayerGuid: () => 0x50000001u,
OnShortcuts: null, OnShortcuts: null,
OnUseDone: null, OnUseDone: null,
ItemMana: new ItemManaState(), ItemMana: new ItemManaState(),
OnDesiredComponents: null,
ExternalContainers: new ExternalContainerState()); ExternalContainers: new ExternalContainerState());
private static LiveCharacterSessionBindings NewCharacterBindings( private static LiveCharacterSessionBindings NewCharacterBindings(
CombatState? combat = null) => new( CombatState? combat = null) => new(
combat ?? new CombatState(), combat ?? new CombatState(),
new Spellbook(), new RuntimeCharacterState(),
ResolveSkillFormulaBonus: null, ResolveSkillFormulaBonus: null,
OnSkillsUpdated: null, OnSkillsUpdated: null,
OnConfirmationRequest: null, OnConfirmationRequest: null,