feat(mosstank): add VTank-style automation PoC

This commit is contained in:
Erik 2026-08-27 18:57:21 +02:00
parent f6fe0f2a4f
commit 4e6e9bc9d9
212 changed files with 49462 additions and 416 deletions

View file

@ -251,6 +251,21 @@ public sealed class ClientObject
/// <summary>Retail <c>PublicWeenieDesc._spellID</c>; used by caster endowments.</summary>
public uint? SpellId { get; set; }
/// <summary>
/// Spell ids retained from this item's latest successful
/// <c>IdentifyObjectResponse</c> SpellBook block. These are item spells,
/// not the local character's learned spellbook; VTank uses them to
/// classify cast-on-strike weapons and item-cast debuffs.
/// </summary>
public IReadOnlyList<uint> AppraisedSpellIds { get; internal set; } =
Array.Empty<uint>();
/// <summary>
/// Monotonic millisecond tick at which the latest successful identify
/// response was received. This is Decal's per-world-object
/// <c>LastIdTime</c>, retained on the canonical object so it disappears
/// with that exact object lifetime.
/// </summary>
public int LastAppraisalTimeMs { get; internal set; }
/// <summary>
/// Retail <c>PublicWeenieDesc._cooldown_id</c>. Positive values name a
/// shared item-cooldown group whose player enchantment id is
/// <c>CooldownId + 0x8000</c>.

View file

@ -774,6 +774,45 @@ public sealed class ClientObjectTable
public bool UpdateProperties(uint itemId, PropertyBundle incoming)
{
if (!_objects.TryGetValue(itemId, out var item)) return false;
MergeProperties(item, incoming);
ApplyCooldownProperties(item, incoming);
ObjectUpdated?.Invoke(item);
return true;
}
/// <summary>
/// Atomically retains every successful item-appraisal result: the typed
/// property tables and the per-item SpellBook block. Publishing one update
/// prevents observers from seeing properties without their matching spell
/// manifest (or the reverse).
/// </summary>
public bool UpdateAppraisal(
uint itemId,
PropertyBundle incoming,
IReadOnlyList<uint> spellIds,
double receivedAtSeconds = 0d)
{
ArgumentNullException.ThrowIfNull(incoming);
ArgumentNullException.ThrowIfNull(spellIds);
if (!_objects.TryGetValue(itemId, out var item)) return false;
MergeProperties(item, incoming);
item.AppraisedSpellIds = spellIds.Count == 0
? Array.Empty<uint>()
: spellIds.ToArray();
if (double.IsFinite(receivedAtSeconds) && receivedAtSeconds >= 0d)
{
long milliseconds = checked((long)Math.Round(
receivedAtSeconds * 1000d,
MidpointRounding.AwayFromZero));
item.LastAppraisalTimeMs = unchecked((int)milliseconds);
}
ApplyCooldownProperties(item, incoming);
ObjectUpdated?.Invoke(item);
return true;
}
private static void MergeProperties(ClientObject item, PropertyBundle incoming)
{
foreach (var kv in incoming.Ints) item.Properties.Ints[kv.Key] = kv.Value;
foreach (var kv in incoming.Int64s) item.Properties.Int64s[kv.Key] = kv.Value;
foreach (var kv in incoming.Bools) item.Properties.Bools[kv.Key] = kv.Value;
@ -781,9 +820,6 @@ public sealed class ClientObjectTable
foreach (var kv in incoming.Strings) item.Properties.Strings[kv.Key] = kv.Value;
foreach (var kv in incoming.DataIds) item.Properties.DataIds[kv.Key] = kv.Value;
foreach (var kv in incoming.InstanceIds) item.Properties.InstanceIds[kv.Key] = kv.Value;
ApplyCooldownProperties(item, incoming);
ObjectUpdated?.Invoke(item);
return true;
}
/// <summary>

View file

@ -2383,7 +2383,11 @@ public sealed class PhysicsEngine
body is not null ? PhysicsResolveCapture.Snapshot(body) : null);
}
return resolveResult;
return resolveResult with
{
LastCollidedObjectId = ci.LastCollidedObjectGuid ?? 0u,
CollidedWithEnvironment = ci.CollidedWithEnvironment,
};
}
finally
{

View file

@ -58,4 +58,16 @@ public readonly record struct ResolveResult(
/// <summary>Full cell that owns <see cref="ContactPlane"/>.</summary>
uint ContactPlaneCellId = 0,
/// <summary>Whether the accepted contact plane is water.</summary>
bool ContactPlaneIsWater = false);
bool ContactPlaneIsWater = false)
{
/// <summary>
/// Last live object touched by this transition, or zero for environment-
/// only/no collision. This is detached collision evidence, not an impact
/// side effect; projectile-awareness callers use it to distinguish the
/// designated target from an intervening creature or prop.
/// </summary>
public uint LastCollidedObjectId { get; init; }
/// <summary>Whether resident environment geometry blocked the sweep.</summary>
public bool CollidedWithEnvironment { get; init; }
}

View file

@ -0,0 +1,142 @@
using AcDream.Plugin.Abstractions;
namespace AcDream.Core.Plugins;
/// <summary>
/// Shared host implementation of the additive plugin-command contract.
/// Registrations are exact leases; callbacks are invoked outside the registry
/// lock so a handler may submit chat or unregister itself without deadlocking.
/// </summary>
public sealed class PluginCommandRegistry : IPluginCommandRegistry
{
private readonly object _gate = new();
private readonly Dictionary<string, Registration> _registrations =
new(StringComparer.OrdinalIgnoreCase);
private readonly Action<string, Exception>? _onFailure;
public PluginCommandRegistry(Action<string, Exception>? onFailure = null)
{
_onFailure = onFailure;
}
public IDisposable Register(string verb, Action<PluginCommand> handler)
{
string normalized = NormalizeVerb(verb);
ArgumentNullException.ThrowIfNull(handler);
var registration = new Registration(this, normalized, handler);
lock (_gate)
{
if (_registrations.ContainsKey(normalized))
{
throw new InvalidOperationException(
$"Plugin command '{normalized}' is already registered.");
}
_registrations.Add(normalized, registration);
}
return registration;
}
/// <summary>Try to consume one complete command-shaped line.</summary>
public bool TryHandle(string rawText)
{
if (string.IsNullOrWhiteSpace(rawText))
return false;
string trimmed = rawText.Trim();
if (trimmed.Length < 2 || trimmed[0] is not ('/' or '@'))
return false;
int separator = trimmed.IndexOfAny([' ', '\t'], 1);
string verb = separator < 0
? trimmed[1..]
: trimmed[1..separator];
if (verb.Length == 0)
return false;
Registration? registration;
lock (_gate)
_registrations.TryGetValue(verb, out registration);
if (registration is null)
return false;
string arguments = separator < 0
? string.Empty
: trimmed[(separator + 1)..].Trim();
try
{
registration.Invoke(new PluginCommand(
registration.Verb,
arguments,
trimmed));
}
catch (Exception error)
{
try
{
_onFailure?.Invoke(registration.Verb, error);
}
catch
{
// Diagnostics observe plugin code; they cannot poison chat.
}
}
return true;
}
private static string NormalizeVerb(string verb)
{
ArgumentException.ThrowIfNullOrWhiteSpace(verb);
string normalized = verb.Trim().TrimStart('/', '@');
if (normalized.Length is < 1 or > 32
|| normalized.Any(static value => !char.IsLetterOrDigit(value)))
{
throw new ArgumentException(
"Plugin command verbs must contain 1-32 letters or digits.",
nameof(verb));
}
return normalized;
}
private void Remove(Registration expected)
{
lock (_gate)
{
if (_registrations.TryGetValue(expected.Verb, out Registration? current)
&& ReferenceEquals(current, expected))
{
_registrations.Remove(expected.Verb);
}
}
}
private sealed class Registration(
PluginCommandRegistry owner,
string verb,
Action<PluginCommand> handler) : IDisposable
{
private readonly object _gate = new();
private PluginCommandRegistry? _owner = owner;
private Action<PluginCommand>? _handler = handler;
internal string Verb { get; } = verb;
internal void Invoke(PluginCommand command)
{
Action<PluginCommand>? callback;
lock (_gate)
callback = _handler;
callback?.Invoke(command);
}
public void Dispose()
{
PluginCommandRegistry? currentOwner;
lock (_gate)
{
currentOwner = _owner;
_owner = null;
_handler = null;
}
currentOwner?.Remove(this);
}
}
}

View file

@ -0,0 +1,151 @@
using AcDream.Plugin.Abstractions;
namespace AcDream.Core.Plugins;
/// <summary>Process-local transactional registry for external loot plugins.</summary>
public sealed class PluginLootClassifierRegistry : IPluginLootClassifierRegistry
{
private readonly object _gate = new();
private readonly Dictionary<string, Entry> _entries =
new(StringComparer.OrdinalIgnoreCase);
public IReadOnlyList<PluginLootClassifierInfo> Available
{
get
{
lock (_gate)
{
return _entries.Values
.Select(static entry => entry.Info)
.OrderBy(static info => info.DisplayName,
StringComparer.OrdinalIgnoreCase)
.ThenBy(static info => info.Id,
StringComparer.OrdinalIgnoreCase)
.ToArray();
}
}
}
public IDisposable Register(
string classifierId,
string displayName,
IPluginLootClassifier classifier)
{
ArgumentException.ThrowIfNullOrWhiteSpace(classifierId);
ArgumentException.ThrowIfNullOrWhiteSpace(displayName);
ArgumentNullException.ThrowIfNull(classifier);
string id = classifierId.Trim();
var entry = new Entry(
new PluginLootClassifierInfo(id, displayName.Trim()),
classifier);
lock (_gate)
{
if (!_entries.TryAdd(id, entry))
{
throw new InvalidOperationException(
$"Loot classifier '{id}' is already registered.");
}
}
return new Registration(this, id, entry);
}
public bool TryClassify(
string classifierId,
in PluginLootClassificationContext context,
out PluginLootClassification classification)
{
Entry? entry;
lock (_gate)
_entries.TryGetValue(classifierId ?? string.Empty, out entry);
if (entry is null)
{
classification = default;
return false;
}
try
{
classification = entry.Classifier.Classify(context);
return true;
}
catch
{
classification = default;
return false;
}
}
public bool TryNotifyLooted(
string classifierId,
in PluginLootedItem item)
{
if (!TryGetClassifier(classifierId, out IPluginLootClassifier classifier))
return false;
try
{
classifier.OnLooted(item);
return true;
}
catch
{
return false;
}
}
public bool TryNotifyItemRemoved(string classifierId, uint objectId)
{
if (!TryGetClassifier(classifierId, out IPluginLootClassifier classifier))
return false;
try
{
classifier.OnItemRemoved(objectId);
return true;
}
catch
{
return false;
}
}
private bool TryGetClassifier(
string classifierId,
out IPluginLootClassifier classifier)
{
classifier = null!;
if (string.IsNullOrWhiteSpace(classifierId))
return false;
lock (_gate)
{
if (!_entries.TryGetValue(classifierId.Trim(), out Entry? entry))
return false;
classifier = entry.Classifier;
return true;
}
}
private void Remove(string id, Entry expected)
{
lock (_gate)
{
if (_entries.TryGetValue(id, out Entry? current)
&& ReferenceEquals(current, expected))
{
_entries.Remove(id);
}
}
}
private sealed record Entry(
PluginLootClassifierInfo Info,
IPluginLootClassifier Classifier);
private sealed class Registration(
PluginLootClassifierRegistry owner,
string id,
Entry entry) : IDisposable
{
private PluginLootClassifierRegistry? _owner = owner;
public void Dispose() => Interlocked.Exchange(ref _owner, null)?
.Remove(id, entry);
}
}

View file

@ -258,7 +258,10 @@ public sealed class PluginSession : IDisposable
{
foreach (PluginDiscoveryResult candidate in available)
{
var scope = new ScopedPluginHost(_host);
var scope = new ScopedPluginHost(
_host,
candidate.Manifest!.Id,
candidate.Manifest.DisplayName);
ScopedRenderPackRegistry? renderPackScope =
candidate.Manifest!.Declares(PluginKind.RenderPack)
&& _renderPacks is not null

View file

@ -13,14 +13,30 @@ internal sealed class ScopedPluginHost : IPluginHost, IDisposable
private readonly ScopedEvents _events;
private readonly ScopedSelectionService _selection;
private readonly ScopedUiRegistry _ui;
private readonly ScopedPluginStorage _storage;
private readonly ScopedPluginCommandRegistry _commands;
private readonly ScopedLootClassifierRegistry _lootClassifiers;
private bool _disposed;
internal ScopedPluginHost(IPluginHost inner)
internal ScopedPluginHost(
IPluginHost inner,
string pluginId,
string pluginDisplayName)
{
_inner = inner ?? throw new ArgumentNullException(nameof(inner));
ArgumentException.ThrowIfNullOrWhiteSpace(pluginId);
ArgumentException.ThrowIfNullOrWhiteSpace(pluginDisplayName);
_events = new ScopedEvents(inner.Events);
_selection = new ScopedSelectionService(inner.Selection);
_ui = new ScopedUiRegistry(inner.Ui);
_ui = new ScopedUiRegistry(
inner.Ui,
new PluginUiOwner(pluginId, pluginDisplayName));
_storage = new ScopedPluginStorage(inner.Storage, pluginId);
_commands = new ScopedPluginCommandRegistry(inner.Commands);
_lootClassifiers = new ScopedLootClassifierRegistry(
inner.LootClassifiers,
pluginId,
pluginDisplayName);
}
public bool HasUi => _inner.HasUi;
@ -29,6 +45,9 @@ internal sealed class ScopedPluginHost : IPluginHost, IDisposable
public IEvents Events => _events;
public ISelectionService Selection => _selection;
public IUiRegistry Ui => _ui;
public IPluginStorage Storage => _storage;
public IPluginCommandRegistry Commands => _commands;
public IPluginLootClassifierRegistry LootClassifiers => _lootClassifiers;
/// <summary>
/// Delegated rather than scoped, unlike <see cref="Events"/>,
@ -45,6 +64,48 @@ internal sealed class ScopedPluginHost : IPluginHost, IDisposable
/// </remarks>
public IAutomationSurface Automation => _inner.Automation;
private sealed class ScopedPluginStorage(
IPluginStorage inner,
string pluginId) : IPluginStorage
{
public bool IsAvailable => inner.IsAvailable;
public string? ReadText(string key) =>
inner.ReadText(ScopedKey(key));
public IReadOnlyList<string> List(string prefix)
{
string scopedPrefix = ScopedKey(prefix);
string ownerPrefix = pluginId + Path.DirectorySeparatorChar;
return inner.List(scopedPrefix)
.Select(key => key.Replace('/', Path.DirectorySeparatorChar))
.Where(key => key.StartsWith(
ownerPrefix,
OperatingSystem.IsWindows()
? StringComparison.OrdinalIgnoreCase
: StringComparison.Ordinal))
.Select(key => key[ownerPrefix.Length..]
.Replace(Path.DirectorySeparatorChar, '/'))
.ToArray();
}
public void WriteText(string key, string content) =>
inner.WriteText(ScopedKey(key), content);
public bool Delete(string key) => inner.Delete(ScopedKey(key));
private static string ValidateKey(string key)
{
ArgumentException.ThrowIfNullOrWhiteSpace(key);
if (Path.IsPathRooted(key)
|| key.Contains("..", StringComparison.Ordinal)
|| key.Contains('\\'))
{
throw new ArgumentException("Invalid plugin storage key.", nameof(key));
}
return key.Replace('/', Path.DirectorySeparatorChar);
}
private string ScopedKey(string key) =>
Path.Combine(pluginId, ValidateKey(key));
}
public void Dispose()
{
if (_disposed)
@ -53,6 +114,127 @@ internal sealed class ScopedPluginHost : IPluginHost, IDisposable
_events.Dispose();
_selection.Dispose();
_ui.Dispose();
_commands.Dispose();
_lootClassifiers.Dispose();
}
private sealed class ScopedLootClassifierRegistry(
IPluginLootClassifierRegistry inner,
string pluginId,
string pluginDisplayName)
: IPluginLootClassifierRegistry,
IDisposable
{
private readonly object _gate = new();
private readonly List<IDisposable> _registrations = [];
private bool _disposed;
public IReadOnlyList<PluginLootClassifierInfo> Available =>
inner.Available;
public IDisposable Register(
string classifierId,
string displayName,
IPluginLootClassifier classifier)
{
ObjectDisposedException.ThrowIf(_disposed, this);
ArgumentException.ThrowIfNullOrWhiteSpace(classifierId);
string local = classifierId.Trim();
if (local.Contains('/') || local.Contains('\\'))
{
throw new ArgumentException(
"A classifier id cannot contain a path separator.",
nameof(classifierId));
}
string effectiveName = string.IsNullOrWhiteSpace(displayName)
? pluginDisplayName
: displayName.Trim();
IDisposable registration = inner.Register(
$"{pluginId}/{local}",
effectiveName,
classifier);
lock (_gate)
{
if (!_disposed)
{
_registrations.Add(registration);
return registration;
}
}
registration.Dispose();
throw new ObjectDisposedException(nameof(ScopedLootClassifierRegistry));
}
public bool TryClassify(
string classifierId,
in PluginLootClassificationContext context,
out PluginLootClassification classification) =>
inner.TryClassify(classifierId, context, out classification);
public bool TryNotifyLooted(
string classifierId,
in PluginLootedItem item) =>
inner.TryNotifyLooted(classifierId, item);
public bool TryNotifyItemRemoved(
string classifierId,
uint objectId) =>
inner.TryNotifyItemRemoved(classifierId, objectId);
public void Dispose()
{
IDisposable[] registrations;
lock (_gate)
{
if (_disposed)
return;
_disposed = true;
registrations = _registrations.ToArray();
_registrations.Clear();
}
for (int index = registrations.Length - 1; index >= 0; index--)
registrations[index].Dispose();
}
}
private sealed class ScopedPluginCommandRegistry(IPluginCommandRegistry inner)
: IPluginCommandRegistry,
IDisposable
{
private readonly object _gate = new();
private readonly List<IDisposable> _registrations = [];
private bool _disposed;
public IDisposable Register(string verb, Action<PluginCommand> handler)
{
ObjectDisposedException.ThrowIf(_disposed, this);
IDisposable registration = inner.Register(verb, handler);
lock (_gate)
{
if (!_disposed)
{
_registrations.Add(registration);
return registration;
}
}
registration.Dispose();
throw new ObjectDisposedException(nameof(ScopedPluginCommandRegistry));
}
public void Dispose()
{
IDisposable[] registrations;
lock (_gate)
{
if (_disposed)
return;
_disposed = true;
registrations = _registrations.ToArray();
_registrations.Clear();
}
for (int index = registrations.Length - 1; index >= 0; index--)
registrations[index].Dispose();
}
}
private sealed class ScopedSelectionService(ISelectionService inner)
@ -297,22 +479,92 @@ internal sealed class ScopedPluginHost : IPluginHost, IDisposable
private sealed class ScopedUiRegistry : IUiRegistry, IDisposable
{
private readonly IScopedUiRegistry _inner;
private readonly PluginUiOwner _owner;
private readonly object _gate = new();
private readonly List<IDisposable> _registrations = [];
private bool _disposed;
internal ScopedUiRegistry(IUiRegistry inner)
internal ScopedUiRegistry(IUiRegistry inner, PluginUiOwner owner)
{
_inner = inner as IScopedUiRegistry
?? throw new InvalidOperationException(
"Plugin hosts must expose an IScopedUiRegistry so UI registrations can be rolled back.");
_owner = owner;
}
public void AddMarkupPanel(string markupPath, object binding)
{
IDisposable registration = _inner.RegisterMarkupPanel(
AddRegistration(_inner.RegisterPanel(
_owner,
new PluginPanelDescriptor(
Path.GetFileNameWithoutExtension(markupPath),
_owner.DisplayName),
markupPath,
binding);
binding));
}
public void AddPanel(
PluginPanelDescriptor descriptor,
string markupPath,
object binding)
{
ArgumentNullException.ThrowIfNull(descriptor);
AddRegistration(_inner.RegisterPanel(
_owner,
descriptor,
markupPath,
binding));
}
public IDisposable RegisterPanel(
PluginPanelDescriptor descriptor,
string markupPath,
object binding)
{
ArgumentNullException.ThrowIfNull(descriptor);
return TrackRegistration(_inner.RegisterPanel(
_owner,
descriptor,
markupPath,
binding));
}
public IDisposable RegisterPanelContent(
PluginPanelDescriptor descriptor,
string markupContent,
object binding)
{
ArgumentNullException.ThrowIfNull(descriptor);
return TrackRegistration(_inner.RegisterPanelContent(
_owner,
descriptor,
markupContent,
binding));
}
public bool ViewExists(string viewName) =>
_inner.ViewExists(_owner, viewName);
public bool IsViewVisible(string viewName) =>
_inner.IsViewVisible(_owner, viewName);
public bool ControlExists(string viewName, string controlName) =>
_inner.ControlExists(_owner, viewName, controlName);
public bool SetControlLabel(
string viewName,
string controlName,
string label) =>
_inner.SetControlLabel(_owner, viewName, controlName, label);
public bool SetControlVisible(
string viewName,
string controlName,
bool visible) =>
_inner.SetControlVisible(_owner, viewName, controlName, visible);
private void AddRegistration(IDisposable registration)
{
lock (_gate)
{
if (!_disposed)
@ -326,6 +578,31 @@ internal sealed class ScopedPluginHost : IPluginHost, IDisposable
throw new ObjectDisposedException(nameof(ScopedUiRegistry));
}
private IDisposable TrackRegistration(IDisposable registration)
{
lock (_gate)
{
if (!_disposed)
{
_registrations.Add(registration);
return new IndividualRegistration(this, registration);
}
}
registration.Dispose();
throw new ObjectDisposedException(nameof(ScopedUiRegistry));
}
private void RemoveRegistration(IDisposable registration)
{
lock (_gate)
{
if (!_registrations.Remove(registration))
return;
}
registration.Dispose();
}
public void Dispose()
{
IDisposable[] registrations;
@ -344,5 +621,15 @@ internal sealed class ScopedPluginHost : IPluginHost, IDisposable
catch { }
}
}
private sealed class IndividualRegistration(
ScopedUiRegistry owner,
IDisposable registration) : IDisposable
{
private ScopedUiRegistry? _owner = owner;
public void Dispose() => Interlocked.Exchange(ref _owner, null)?
.RemoveRegistration(registration);
}
}
}