diff --git a/AcDream.slnx b/AcDream.slnx index b1153876..34d093fb 100644 --- a/AcDream.slnx +++ b/AcDream.slnx @@ -11,6 +11,7 @@ + @@ -46,6 +47,7 @@ + diff --git a/src/AcDream.App/AcDream.App.csproj b/src/AcDream.App/AcDream.App.csproj index 77745cf1..d2137f6a 100644 --- a/src/AcDream.App/AcDream.App.csproj +++ b/src/AcDream.App/AcDream.App.csproj @@ -146,4 +146,53 @@ Overwrite="true" Lines="{ "id": "acdream.smoke", "displayName": "Smoke Plugin", "version": "0.1.0", "entryDll": "AcDream.Plugins.Smoke.dll", "apiVersion": 1 }" /> + + + + + false + true + + + + + <_MossTankSourceDir>$(MSBuildProjectDirectory)/../AcDream.Plugins.MossTank/bin/$(Configuration)/$(TargetFramework) + <_MossTankSourceDir Condition="'$(RuntimeIdentifier)' != ''">$(_MossTankSourceDir)/$(RuntimeIdentifier) + <_MossTankDestDir>$(OutputPath)plugins/AcDream.Plugins.MossTank + + + + + + + + + <_MossTankPublishSourceDir>$(MSBuildProjectDirectory)/../AcDream.Plugins.MossTank/bin/$(Configuration)/$(TargetFramework) + <_MossTankPublishSourceDir Condition="'$(RuntimeIdentifier)' != ''">$(_MossTankPublishSourceDir)/$(RuntimeIdentifier) + <_MossTankPublishDestDir>$(PublishDir)plugins/AcDream.Plugins.MossTank + + + + + diff --git a/src/AcDream.App/Plugins/AppAutomationSurface.cs b/src/AcDream.App/Plugins/AppAutomationSurface.cs new file mode 100644 index 00000000..c395fb94 --- /dev/null +++ b/src/AcDream.App/Plugins/AppAutomationSurface.cs @@ -0,0 +1,275 @@ +using AcDream.Core.Player; +using AcDream.Core.Spells; +using AcDream.Plugin.Abstractions; +using AcDream.Runtime.Gameplay; + +namespace AcDream.App.Plugins; + +/// +/// Projects the canonical Runtime gameplay owners into the BCL-only plugin +/// automation surface. +/// +/// +/// +/// Owns nothing: the character state and cast state are borrowed from +/// GameRuntime and rebound per session, matching how every other +/// graphical projection treats Runtime owners. Between sessions the surface +/// reports false and every command refuses, rather +/// than throwing at a plugin that ticked one frame late. +/// +/// +/// The two snapshot lists are rebuilt on Spellbook change notifications rather +/// than per read, because a plugin ticking each frame would otherwise force a +/// full spellbook walk 60 times a second for data that changes rarely. +/// +/// +internal sealed class AppAutomationSurface + : IAutomationSurface, ICharacterInfo, ISpellCatalog, IMagicCommands, IDisposable +{ + private readonly object _gate = new(); + + private RuntimeCharacterState? _character; + private RuntimeSpellCastState? _cast; + private Spellbook? _spellbook; + private bool _disposed; + + private IReadOnlyList _knownSelfBuffs = Array.Empty(); + private IReadOnlyList _enchantments = + Array.Empty(); + + public bool IsAvailable + { + get + { + lock (_gate) + return !_disposed && _character is not null && _cast is not null; + } + } + + public ICharacterInfo Character => this; + public ISpellCatalog Spells => this; + public IMagicCommands Magic => this; + + /// Bind the surface to a live session's owners. + public void Bind(RuntimeCharacterState character, RuntimeSpellCastState cast) + { + ArgumentNullException.ThrowIfNull(character); + ArgumentNullException.ThrowIfNull(cast); + + Spellbook spellbook = character.Spellbook; + lock (_gate) + { + if (_disposed) + return; + DetachLocked(); + _character = character; + _cast = cast; + _spellbook = spellbook; + spellbook.SpellbookChanged += OnSpellbookChanged; + spellbook.EnchantmentsChanged += OnEnchantmentsChanged; + } + + RebuildSpellbook(); + RebuildEnchantments(); + } + + /// Release the session's owners; reads go inert until the next bind. + public void Unbind() + { + lock (_gate) + DetachLocked(); + _knownSelfBuffs = Array.Empty(); + _enchantments = Array.Empty(); + } + + private void DetachLocked() + { + if (_spellbook is not null) + { + _spellbook.SpellbookChanged -= OnSpellbookChanged; + _spellbook.EnchantmentsChanged -= OnEnchantmentsChanged; + } + _spellbook = null; + _character = null; + _cast = null; + } + + private void OnSpellbookChanged() => RebuildSpellbook(); + + private void OnEnchantmentsChanged() => RebuildEnchantments(); + + private void RebuildSpellbook() + { + Spellbook? spellbook; + lock (_gate) + spellbook = _spellbook; + if (spellbook is null) + { + _knownSelfBuffs = Array.Empty(); + return; + } + + var built = new List(); + foreach (uint spellId in spellbook.LearnedSpells) + { + if (!spellbook.TryGetMetadata(spellId, out SpellMetadata meta)) + continue; + if (!meta.IsSelfTargeted || !meta.IsBeneficial || meta.IsDebuff) + continue; + built.Add(Project(meta)); + } + + // Stable order so a plugin's buff sequence does not reshuffle between + // passes: family first, then strongest tier within it. + built.Sort(static (a, b) => + a.Family != b.Family + ? a.Family.CompareTo(b.Family) + : b.Tier.CompareTo(a.Tier)); + _knownSelfBuffs = built; + } + + private void RebuildEnchantments() + { + Spellbook? spellbook; + lock (_gate) + spellbook = _spellbook; + if (spellbook is null) + { + _enchantments = Array.Empty(); + return; + } + + // EnchantmentsInEffectSnapshot, not the raw active set: retail lets a + // weaker same-family enchantment sit in the registry while a stronger + // one is in force. A plugin asking "am I buffed?" means in force. + IReadOnlyList active = + spellbook.EnchantmentsInEffectSnapshot; + var built = new List(active.Count); + foreach (ActiveEnchantmentRecord record in active) + { + uint family = 0; + int tier = 0; + if (spellbook.TryGetMetadata(record.SpellId, out SpellMetadata meta)) + { + family = meta.Family; + tier = meta.Generation; + } + built.Add(new PluginActiveEnchantment( + record.SpellId, family, tier, record.Duration)); + } + _enchantments = built; + } + + private static PluginSpellInfo Project(SpellMetadata meta) => new( + meta.SpellId, + meta.Name, + meta.Family, + meta.Generation, + meta.Difficulty, + meta.ManaCost, + meta.Duration, + meta.IsSelfTargeted, + meta.IsBeneficial); + + // ── ICharacterInfo ──────────────────────────────────────────────────── + public bool IsInWorld => IsAvailable; + + public uint CurrentMana => Vital(out uint current, out _) ? current : 0u; + + public uint MaxMana => Vital(out _, out uint maximum) ? maximum : 0u; + + private bool Vital(out uint current, out uint maximum) + { + current = 0; + maximum = 0; + RuntimeCharacterState? character; + lock (_gate) + character = _character; + if (character is null) + return false; + if (!character.View.TryGetVital( + (int)LocalPlayerState.VitalKind.Mana, out var vital)) + { + return false; + } + current = vital.Current; + maximum = vital.Maximum; + return true; + } + + public IReadOnlyList ActiveEnchantments => _enchantments; + + // ── ISpellCatalog ───────────────────────────────────────────────────── + public IReadOnlyList KnownSelfBuffs => _knownSelfBuffs; + + public bool TryGet(uint spellId, out PluginSpellInfo info) + { + Spellbook? spellbook; + lock (_gate) + spellbook = _spellbook; + if (spellbook is not null + && spellbook.TryGetMetadata(spellId, out SpellMetadata meta)) + { + info = Project(meta); + return true; + } + info = default; + return false; + } + + // ── IMagicCommands ──────────────────────────────────────────────────── + public bool IsCasting + { + get + { + RuntimeSpellCastState? cast; + lock (_gate) + cast = _cast; + return cast?.LastRequestedSpellId is not null; + } + } + + public PluginCastGate EvaluateGate(uint spellId) + { + RuntimeSpellCastState? cast; + Spellbook? spellbook; + lock (_gate) + { + cast = _cast; + spellbook = _spellbook; + } + if (cast is null || spellbook is null) + return PluginCastGate.Unavailable; + if (!spellbook.Knows(spellId)) + return PluginCastGate.NotKnown; + + return cast.EvaluateCastGate(spellId) switch + { + SpellCastGate.Unknown => PluginCastGate.NotKnown, + SpellCastGate.NoTargetNeeded => PluginCastGate.Ready, + SpellCastGate.TargetCompatible => PluginCastGate.Ready, + _ => PluginCastGate.Refused, + }; + } + + public bool Cast(uint spellId) + { + RuntimeSpellCastState? cast; + lock (_gate) + cast = _cast; + return cast is not null && cast.Cast(spellId) == CastRequestResult.Sent; + } + + public void Dispose() + { + lock (_gate) + { + if (_disposed) + return; + _disposed = true; + DetachLocked(); + } + _knownSelfBuffs = Array.Empty(); + _enchantments = Array.Empty(); + } +} diff --git a/src/AcDream.App/Plugins/AppPluginHost.cs b/src/AcDream.App/Plugins/AppPluginHost.cs index dc81ec44..ad5fe525 100644 --- a/src/AcDream.App/Plugins/AppPluginHost.cs +++ b/src/AcDream.App/Plugins/AppPluginHost.cs @@ -9,13 +9,15 @@ public sealed class AppPluginHost : IPluginHost IGameState state, IEvents events, ISelectionService selection, - IUiRegistry ui) + IUiRegistry ui, + IAutomationSurface automation) { Log = log; State = state; Events = events; Selection = selection; Ui = ui; + Automation = automation; } public bool HasUi => true; @@ -24,4 +26,5 @@ public sealed class AppPluginHost : IPluginHost public IEvents Events { get; } public ISelectionService Selection { get; } public IUiRegistry Ui { get; } + public IAutomationSurface Automation { get; } } diff --git a/src/AcDream.App/Program.cs b/src/AcDream.App/Program.cs index 8b0407bb..d62cbb7b 100644 --- a/src/AcDream.App/Program.cs +++ b/src/AcDream.App/Program.cs @@ -149,18 +149,23 @@ if (runtimeOptions.DevTools) var worldGameState = new AcDream.Core.Plugins.WorldGameState(); var worldEvents = new AcDream.Core.Plugins.WorldEvents(); var uiRegistry = new AcDream.App.Plugins.BufferedUiRegistry(); +// Constructed here and handed to both sides: GameWindow binds it to the live +// session's Runtime owners, the plugin host exposes it to plugins. +using var automation = new AcDream.App.Plugins.AppAutomationSurface(); using var window = new GameWindow( runtimeOptions, worldGameState, worldEvents, uiRegistry, - graphicalPlatform); + graphicalPlatform, + automation); var host = new AppPluginHost( new SerilogAdapter(Log.Logger), worldGameState, worldEvents, window.Selection, - uiRegistry); + uiRegistry, + automation); GraphicalPluginSession pluginSession = GraphicalPluginSession.Create( applicationPaths, runtimeOptions.Plugins, diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index b6de89db..ededbe4e 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -363,6 +363,7 @@ public sealed class GameWindow : // J4/J5.1: Runtime owns communication, selection, combat, and target-mode // state. App, UI, plugins, and live-session routing borrow exact children. + private readonly AcDream.App.Plugins.AppAutomationSurface? _automation; private readonly GameRuntime _runtime; private readonly IDisposable _runtimeHostLease; private RuntimeCommunicationState _runtimeCommunication => @@ -634,9 +635,11 @@ public sealed class GameWindow : WorldGameState worldGameState, WorldEvents worldEvents, AcDream.App.Plugins.BufferedUiRegistry? uiRegistry, - GraphicalHostPlatformServices platformServices) + GraphicalHostPlatformServices platformServices, + AcDream.App.Plugins.AppAutomationSurface? automation = null) { _options = options ?? throw new System.ArgumentNullException(nameof(options)); + _automation = automation; _statusWriter = new SessionStatusWriter(options.StatusFilePath); _platformServices = platformServices ?? throw new ArgumentNullException(nameof(platformServices)); @@ -653,6 +656,11 @@ public sealed class GameWindow : options.DumpSky ? Console.WriteLine : null)); _runtimeHostLease = _runtime.AcquireHostLease( "graphical GameWindow"); + // Bound once, not per session: since Slice J the gameplay owners are + // stable for the GameRuntime's lifetime and it is their *contents* that + // reset across generations. Re-binding per session would be re-binding + // the same two references. + _automation?.Bind(_runtime.CharacterOwner, _runtime.ActionOwner.SpellCast); _localPlayerIdentity = new AcDream.App.Input.LocalPlayerIdentityState( _runtime.PlayerIdentity); _updateFrameClock = new AcDream.App.Update.UpdateFrameClock( @@ -1626,6 +1634,11 @@ public sealed class GameWindow : using var _updStage = _frameProfiler.BeginStage( AcDream.App.Diagnostics.FrameStage.Update); _frameGraphs.Tick(new AcDream.App.Update.UpdateFrameInput(dt)); + // After the frame graph, so a plugin observes state the host has + // already advanced this frame rather than a half-updated world. + // WorldEvents.FireTick swallows per-subscriber faults, so a throwing + // plugin cannot break the render loop. + _worldEvents.FireTick(dt); _renderLoopArmed = false; } diff --git a/src/AcDream.App/UI/MarkupDocument.cs b/src/AcDream.App/UI/MarkupDocument.cs index 1132479b..410aece4 100644 --- a/src/AcDream.App/UI/MarkupDocument.cs +++ b/src/AcDream.App/UI/MarkupDocument.cs @@ -75,12 +75,100 @@ public static class MarkupDocument FrontRight = Hex((string?)el.Attribute("frontright")), }); break; - // future element kinds (label, button, image) added here + + case "label": + // Text may be a literal or a {Binding}. Bound labels re-read + // their property every frame through the Func, so a plugin + // updates its status line by assigning a property rather + // than by touching UI objects from its own thread. + var label = new UiLabel + { + Left = F(el, "x"), + Top = F(el, "y"), + TextSource = BindString((string?)el.Attribute("text"), binding), + }; + if (el.Attribute("color") is not null) + label.TextColor = Color((string?)el.Attribute("color")); + panel.AddChild(label); + break; + + case "button": + // onclick binds to an Action property on the binding + // object. Resolved once at build time: a button whose + // handler silently failed to bind is a bug worth failing + // loudly for, and MarkupDocument.Build is already inside + // the host's try/catch that reports panel load failures. + string? clickName = (string?)el.Attribute("onclick"); + Action? onClick = BindAction(clickName, binding); + if (clickName is not null && onClick is null) + { + throw new FormatException( + $"