Owner direction 2026-09-07 (same instruction as the sibling unknown- command fix, previous commit): plugin-originated text must land in the chat window instead of retail's ClientLocal (0x1A) SpewBox-only channel. AppAutomationSurface.PostSystemMessage -- the production implementation of IPluginChat.PostSystemMessage that MossTank/VTank- style plugins call -- now passes RetailLogTextType.Default instead of ClientLocal to RuntimeCommunicationState.AddText, so the text reaches the chat transcript via Chat.OnSystemMessage instead of the SpewBox. This matches Decal's own AddChatText behavior for plugin output. IPluginChat.PostSystemMessage's doc comment is updated to describe the new destination instead of the old one. Register row AD-124 (previous commit) already covers this site alongside the sibling unknown-command change. Mutation check: temporarily reverted PostSystemMessage's AddText call back to ClientLocal and confirmed the new AppAutomationSurfaceTests.PostSystemMessage_RoutesToChatLog_NeverSpewBox test fails (Assert.Single() on an empty chat log) before restoring the fix. Also adds ChatVMTests.RecentLines_ShowsPluginSystemMessage_TaggedDefault pinning that a ChatVM bound to the same ChatLog surfaces the line. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
345 lines
13 KiB
C#
345 lines
13 KiB
C#
using AcDream.App.Plugins;
|
|
using AcDream.Core.Chat;
|
|
using AcDream.Core.Items;
|
|
using AcDream.Core.Physics;
|
|
using AcDream.Core.Selection;
|
|
using AcDream.Core.Spells;
|
|
using AcDream.Plugin.Abstractions;
|
|
using AcDream.Runtime.Gameplay;
|
|
using System.Numerics;
|
|
|
|
namespace AcDream.App.Tests.Plugins;
|
|
|
|
public sealed class AppAutomationSurfaceTests
|
|
{
|
|
[Fact]
|
|
public void ProjectileDebugSamplesAreDetachedValidatedAndClearedOnUnbind()
|
|
{
|
|
using var surface = new AppAutomationSurface();
|
|
PluginProjectileDebugSample[] source =
|
|
[
|
|
new(new Vector3(1f, 2f, 3f), true, 0.4f),
|
|
new(new Vector3(float.NaN, 0f, 0f), false, 0.4f),
|
|
];
|
|
|
|
surface.Projectiles.ShowDebugSamples(source);
|
|
source[0] = default;
|
|
|
|
PluginProjectileDebugSample sample = Assert.Single(
|
|
surface.CaptureProjectileDebugSamples());
|
|
Assert.Equal(new Vector3(1f, 2f, 3f), sample.WorldPosition);
|
|
Assert.True(sample.IsClear);
|
|
|
|
surface.Unbind();
|
|
Assert.Empty(surface.CaptureProjectileDebugSamples());
|
|
}
|
|
|
|
[Fact]
|
|
public void SelectionAutomationUsesTheBoundCanonicalActionRoute()
|
|
{
|
|
using var surface = new AppAutomationSurface();
|
|
var actions = new List<PluginSelectionAction>();
|
|
surface.BindSelectionActions(action =>
|
|
{
|
|
actions.Add(action);
|
|
return true;
|
|
});
|
|
|
|
Assert.True(surface.Selection.Execute(
|
|
PluginSelectionAction.PreviousSelection));
|
|
Assert.True(surface.Selection.Execute(
|
|
PluginSelectionAction.NextPlayer));
|
|
Assert.Equal(
|
|
[
|
|
PluginSelectionAction.PreviousSelection,
|
|
PluginSelectionAction.NextPlayer,
|
|
],
|
|
actions);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData((uint)ItemType.MeleeWeapon, 0u, PluginObjectClass.MeleeWeapon)]
|
|
[InlineData((uint)ItemType.Armor, 0u, PluginObjectClass.Armor)]
|
|
[InlineData((uint)ItemType.Creature, 0x10u, PluginObjectClass.Monster)]
|
|
[InlineData((uint)ItemType.Creature, 0u, PluginObjectClass.Npc)]
|
|
[InlineData((uint)ItemType.Creature, 0x04000010u, PluginObjectClass.CombatPet)]
|
|
[InlineData((uint)ItemType.Creature, 0x8u, PluginObjectClass.Player)]
|
|
[InlineData((uint)ItemType.Misc, 0x200u, PluginObjectClass.Vendor)]
|
|
[InlineData((uint)ItemType.Misc, 0x1000u, PluginObjectClass.Door)]
|
|
public void ObjectClassProjectionMatchesVirindiPriority(
|
|
uint itemType,
|
|
uint publicFlags,
|
|
PluginObjectClass expected)
|
|
{
|
|
var item = new ClientObject
|
|
{
|
|
ObjectId = 1u,
|
|
Type = (ItemType)itemType,
|
|
PublicWeenieBitfield = publicFlags,
|
|
};
|
|
|
|
Assert.Equal(expected, AppAutomationSurface.ClassifyObject(item));
|
|
}
|
|
|
|
[Fact]
|
|
public void NavigationProjectionUsesVtankMapCoordinatesAndCompassHeading()
|
|
{
|
|
PluginNavigationPosition center =
|
|
AppAutomationSurface.ProjectNavigationPosition(new Position(
|
|
0x7F7F0001u,
|
|
new Vector3(84f, 84f, 240f),
|
|
Quaternion.Identity));
|
|
|
|
Assert.Equal(0d, center.EastWest, 8);
|
|
Assert.Equal(0d, center.NorthSouth, 8);
|
|
Assert.Equal(1d, center.Elevation, 8);
|
|
Assert.Equal(0f, center.HeadingDegrees, 4);
|
|
Assert.True(center.IsOutdoor);
|
|
|
|
PluginNavigationPosition nextBlock =
|
|
AppAutomationSurface.ProjectNavigationPosition(new Position(
|
|
0x80800041u,
|
|
new Vector3(84f, 84f, 0f),
|
|
Quaternion.Identity));
|
|
|
|
Assert.Equal(0.8d, nextBlock.EastWest, 8);
|
|
Assert.Equal(0.8d, nextBlock.NorthSouth, 8);
|
|
Assert.False(nextBlock.IsOutdoor);
|
|
}
|
|
|
|
[Fact]
|
|
public void ChatCapture_isOrderedCursorBasedAndDetachesAcrossSessions()
|
|
{
|
|
using var first = GameRuntimeTestFactory.Create();
|
|
using var second = GameRuntimeTestFactory.Create();
|
|
using var surface = new AppAutomationSurface();
|
|
surface.Bind(
|
|
first,
|
|
first.CharacterOwner,
|
|
first.ActionOwner.SpellCast);
|
|
|
|
first.CommunicationOwner.AddText(
|
|
"You cast Imperil Other VII on Olthoi.",
|
|
RetailLogTextType.Magic);
|
|
PluginChatMessage one = Assert.Single(surface.CaptureMessages(0));
|
|
Assert.Equal("You cast Imperil Other VII on Olthoi.", one.Text);
|
|
Assert.Empty(surface.CaptureMessages(one.Sequence));
|
|
|
|
surface.Bind(
|
|
second,
|
|
second.CharacterOwner,
|
|
second.ActionOwner.SpellCast);
|
|
first.CommunicationOwner.AddText(
|
|
"stale first-session line",
|
|
RetailLogTextType.Magic);
|
|
second.CommunicationOwner.AddText(
|
|
"You cast Fester Other VII on Olthoi.",
|
|
RetailLogTextType.Magic);
|
|
|
|
PluginChatMessage two = Assert.Single(
|
|
surface.CaptureMessages(one.Sequence));
|
|
Assert.True(two.Sequence > one.Sequence);
|
|
Assert.Equal("You cast Fester Other VII on Olthoi.", two.Text);
|
|
Assert.Equal(1, second.CommunicationOwner.SubscriberCount);
|
|
|
|
surface.Dispose();
|
|
Assert.Equal(0, second.CommunicationOwner.SubscriberCount);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Owner-directed override 2026-09-07 (register row AD-124): plugin
|
|
/// output ("Unknown commands like /vt or stuff from plugins ... should
|
|
/// go to the chatbox") must land in the chat log, never the transient
|
|
/// SpewBox overlay retail's own ClientLocal (0x1A) typing used to send
|
|
/// it to — the same VTank-faithful destination Decal's own
|
|
/// <c>AddChatText</c> uses.
|
|
/// </summary>
|
|
[Fact]
|
|
public void PostSystemMessage_RoutesToChatLog_NeverSpewBox()
|
|
{
|
|
using var runtime = GameRuntimeTestFactory.Create();
|
|
using var surface = new AppAutomationSurface();
|
|
surface.Bind(runtime, runtime.CharacterOwner, runtime.ActionOwner.SpellCast);
|
|
|
|
surface.PostSystemMessage("MossTank: buffs applied.");
|
|
|
|
var entry = Assert.Single(runtime.CommunicationOwner.Chat.Snapshot());
|
|
Assert.Equal("MossTank: buffs applied.", entry.Text);
|
|
Assert.Equal((uint)RetailLogTextType.Default, entry.LogTextType);
|
|
|
|
runtime.CommunicationOwner.SpewBox.Tick(0d);
|
|
Assert.Equal(0, runtime.CommunicationOwner.SpewBox.Count);
|
|
}
|
|
|
|
[Fact]
|
|
public void InventoryCompletionProjectsTheCanonicalRequestReceipt()
|
|
{
|
|
using var runtime = GameRuntimeTestFactory.Create();
|
|
using var surface = new AppAutomationSurface();
|
|
surface.Bind(runtime, runtime.CharacterOwner, runtime.ActionOwner.SpellCast);
|
|
ClientObjectTable objects = runtime.InventoryOwner.Objects;
|
|
const uint itemId = 0x50000123u;
|
|
objects.AddOrUpdate(new ClientObject
|
|
{
|
|
ObjectId = itemId,
|
|
Name = "Stack",
|
|
StackSize = 10,
|
|
StackSizeMax = 100,
|
|
});
|
|
|
|
Assert.True(runtime.InventoryOwner.Transactions.TryDispatch(
|
|
InventoryRequestKind.Merge,
|
|
itemId,
|
|
static () => true));
|
|
Assert.True(objects.UpdateStackSize(itemId, 9, 0));
|
|
|
|
PluginInventoryCompletion completion =
|
|
surface.Items.LastInventoryCompletion;
|
|
Assert.True(completion.Revision > 0);
|
|
Assert.Equal(PluginInventoryCommandKind.Merge, completion.Kind);
|
|
Assert.Equal(itemId, completion.SourceObjectId);
|
|
Assert.True(completion.IsSuccess);
|
|
}
|
|
|
|
[Fact]
|
|
public void RecoveryClearsExactlyOneCanonicalBusyReference()
|
|
{
|
|
using var runtime = GameRuntimeTestFactory.Create();
|
|
using var surface = new AppAutomationSurface();
|
|
surface.Bind(runtime, runtime.CharacterOwner, runtime.ActionOwner.SpellCast);
|
|
runtime.InventoryOwner.Transactions.IncrementBusyCount();
|
|
runtime.InventoryOwner.Transactions.IncrementBusyCount();
|
|
|
|
PluginRecoveryResult first = surface.Recovery.ClearOneBusyReference();
|
|
PluginRecoveryResult second = surface.Recovery.ClearOneBusyReference();
|
|
PluginRecoveryResult alreadyClear =
|
|
surface.Recovery.ClearOneBusyReference();
|
|
|
|
Assert.True(first.Accepted);
|
|
Assert.Equal((2, 1), (first.PreviousCount, first.CurrentCount));
|
|
Assert.Equal((1, 0), (second.PreviousCount, second.CurrentCount));
|
|
Assert.Equal((0, 0),
|
|
(alreadyClear.PreviousCount, alreadyClear.CurrentCount));
|
|
Assert.Equal(0, runtime.InventoryOwner.Transactions.BusyCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void EnchantmentLedgerSharesReportedAndConfirmedLocalDurationCasts()
|
|
{
|
|
var operations = new SpellOperations();
|
|
using var runtime = GameRuntimeTestFactory.Create(spellCast: operations);
|
|
runtime.CharacterOwner.InstallSpellMetadata(SpellTable.Create([DurationSpell()]));
|
|
runtime.CharacterOwner.Spellbook.OnSpellLearned(42u);
|
|
using var surface = new AppAutomationSurface();
|
|
surface.Bind(runtime, runtime.CharacterOwner, runtime.ActionOwner.SpellCast);
|
|
|
|
Assert.True(surface.Enchantments.ReportCast(100u, 42u, 30d));
|
|
PluginTrackedEnchantment reported = Assert.Single(
|
|
surface.Enchantments.Capture(100u));
|
|
Assert.Equal(7u, reported.Family);
|
|
Assert.Equal(350, reported.Quality);
|
|
Assert.InRange(reported.SecondsRemaining, 29d, 30d);
|
|
|
|
runtime.ActionOwner.Selection.Select(
|
|
200u,
|
|
SelectionChangeSource.Plugin);
|
|
Assert.Equal(
|
|
CastRequestResult.Sent,
|
|
runtime.ActionOwner.SpellCast.Cast(42u));
|
|
Assert.True(runtime.ActionOwner.SpellCast.CompleteUse(0u));
|
|
|
|
Assert.True(surface.Magic.LastCompletion.IsSuccess);
|
|
PluginTrackedEnchantment local = Assert.Single(
|
|
surface.Enchantments.Capture(200u));
|
|
Assert.Equal(42u, local.SpellId);
|
|
Assert.InRange(local.SecondsRemaining, 59d, 60d);
|
|
|
|
surface.Unbind();
|
|
Assert.Empty(surface.Enchantments.Capture(100u));
|
|
Assert.Empty(surface.Enchantments.Capture(200u));
|
|
}
|
|
|
|
private static SpellMetadata DurationSpell() => new(
|
|
42u,
|
|
"Fire Vulnerability Other VII",
|
|
"Life Magic",
|
|
7u,
|
|
0u,
|
|
string.Empty,
|
|
60f,
|
|
10,
|
|
true,
|
|
false,
|
|
string.Empty,
|
|
0,
|
|
350,
|
|
0u,
|
|
7,
|
|
false,
|
|
true,
|
|
false,
|
|
0f,
|
|
0u,
|
|
0u,
|
|
1u,
|
|
0);
|
|
|
|
private sealed class SpellOperations : IRuntimeSpellCastOperations
|
|
{
|
|
public uint LocalPlayerId => 1u;
|
|
public bool CanSend => true;
|
|
public bool HasRequiredComponents(uint spellId) => true;
|
|
public bool IsTargetCompatible(
|
|
uint targetId,
|
|
SpellMetadata spell,
|
|
bool showMessage) => true;
|
|
public void StopCompletely() { }
|
|
public void SendUntargeted(uint spellId) { }
|
|
public void SendTargeted(uint targetId, uint spellId) { }
|
|
public void DisplayMessage(string message) { }
|
|
public void IncrementBusy() { }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Review fix round finding 14 (docs/plans/2026-09-06-plugin-shelf-and-dat-icons.md
|
|
/// Slice B): <see cref="PluginWorldObject.IconId"/> must carry
|
|
/// <see cref="ClientObject.IconId"/> through <c>AppAutomationSurface.ProjectWorldObject</c>
|
|
/// unchanged. That projector is private and its public callers
|
|
/// (<see cref="IWorldObjectAutomation.CaptureObjects"/>/<c>TryGet</c>)
|
|
/// gate on <see cref="AppAutomationSurface.IsAvailable"/>, which requires
|
|
/// a fully connected session (<c>RuntimeLifecycleState.InWorld</c>) —
|
|
/// heavier than this mapping needs. Per the plan's own fallback ("an
|
|
/// InstalledDat-free unit on the projection function is enough"), this
|
|
/// invokes the private projector directly via reflection: an
|
|
/// InstalledDat-free unit on exactly the projection function.
|
|
/// <see cref="PluginInventoryItem.IconId"/> is filled by the identical
|
|
/// one-line pattern (<c>item.IconId</c>) inline in
|
|
/// <c>CaptureOwnedItems</c>, reviewed by inspection rather than
|
|
/// duplicated here.
|
|
/// </summary>
|
|
[Fact]
|
|
public void ProjectWorldObject_FillsIconIdFromTheClientObject()
|
|
{
|
|
using var runtime = GameRuntimeTestFactory.Create();
|
|
using var surface = new AppAutomationSurface();
|
|
var item = new ClientObject
|
|
{
|
|
ObjectId = 0x50000456u,
|
|
Name = "Test Item",
|
|
IconId = 0x06000165u,
|
|
};
|
|
|
|
System.Reflection.MethodInfo method = typeof(AppAutomationSurface)
|
|
.GetMethod(
|
|
"ProjectWorldObject",
|
|
System.Reflection.BindingFlags.NonPublic
|
|
| System.Reflection.BindingFlags.Instance)
|
|
?? throw new InvalidOperationException(
|
|
"AppAutomationSurface.ProjectWorldObject was not found by reflection.");
|
|
var result = (PluginWorldObject)method.Invoke(
|
|
surface,
|
|
[runtime, null, item, 0u])!;
|
|
|
|
Assert.Equal(0x06000165u, result.IconId);
|
|
}
|
|
}
|