diff --git a/.gitignore b/.gitignore
index 9491a2f..8cb9ff0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -360,4 +360,14 @@ MigrationBackup/
.ionide/
# Fody - auto-generated XML schema
-FodyWeavers.xsd
\ No newline at end of file
+FodyWeavers.xsd
+/UI_Creation_Manual.md
+/UI_Creation_VirindiViewService_Manual.md
+/MosswartMassacre/Unused
+/MosswartMassacre/Decal.Adapter.csproj
+/MosswartMassacre/Decal.Interop.Core.csproj
+
+# Claude Code
+.claude/
+CLAUDE.md
+**/CLAUDE.md
diff --git a/GearCycler/GearCore.cs b/GearCycler/GearCore.cs
deleted file mode 100644
index 09b380a..0000000
--- a/GearCycler/GearCore.cs
+++ /dev/null
@@ -1,45 +0,0 @@
-using System;
-using System.IO;
-using System.Runtime.InteropServices;
-using Decal.Adapter;
-using Decal.Adapter.Wrappers;
-using VirindiViewService;
-using VirindiViewService.Controls;
-
-namespace GearCycler
-{
- [ComVisible(true)]
- [Guid("9b6a07e1-ae78-47f4-b09c-174f6a27d7a3")] // Replace with your own unique GUID if needed
- [FriendlyName("GearCycler")]
- public class GearCore : PluginBase
- {
- public HudView view;
- private HudButton btnCycle;
-
- protected override void Startup()
- {
- try
- {
- string xml = File.ReadAllText("ViewXML\\mainview.xml");
- view = HudView.ReadXmlLayout(xml);
- view.Visible = true;
-
- btnCycle = (HudButton)view.Controls["btnCycle"];
- btnCycle.Hit += (s, e) =>
- {
- CoreManager.Current.Actions.AddChatText("[GearCycler] Button clicked!", 1);
- };
- }
- catch (Exception ex)
- {
- CoreManager.Current.Actions.AddChatText($"[GearCycler] Failed to load UI: {ex.Message}", 1);
- }
- }
-
- protected override void Shutdown()
- {
- btnCycle?.Dispose();
- view?.Dispose();
- }
- }
-}
diff --git a/GearCycler/GearCycler.csproj b/GearCycler/GearCycler.csproj
deleted file mode 100644
index b4344de..0000000
--- a/GearCycler/GearCycler.csproj
+++ /dev/null
@@ -1,81 +0,0 @@
-
-
-
-
- Debug
- AnyCPU
- {1293560E-2A56-417F-8116-8CE0420DC97C}
- Library
- Properties
- GearCycler
- GearCycler
- v4.8
- 512
- true
-
-
- true
- full
- false
- bin\Debug\
- TRACE;DEBUG;VVS_REFERENCED;DECAL_INTEROP
- prompt
- 4
- x86
-
-
- pdbonly
- true
- bin\Release\
- TRACE
- prompt
- 4
-
-
-
- ..\MosswartMassacre\lib\Decal.Adapter.dll
-
-
- False
- True
- ..\MosswartMassacre\lib\Decal.Interop.Core.DLL
-
-
- False
- True
- ..\MosswartMassacre\lib\Decal.Interop.Inject.dll
-
-
-
-
-
-
-
-
-
-
-
- ..\MosswartMassacre\lib\VirindiViewService.dll
-
-
-
-
-
-
- True
- True
- Resources.resx
-
-
-
-
- ResXFileCodeGenerator
- Resources.Designer.cs
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/GearCycler/Properties/AssemblyInfo.cs b/GearCycler/Properties/AssemblyInfo.cs
deleted file mode 100644
index c45a3b8..0000000
--- a/GearCycler/Properties/AssemblyInfo.cs
+++ /dev/null
@@ -1,33 +0,0 @@
-using System.Reflection;
-using System.Runtime.CompilerServices;
-using System.Runtime.InteropServices;
-
-// General Information about an assembly is controlled through the following
-// set of attributes. Change these attribute values to modify the information
-// associated with an assembly.
-[assembly: AssemblyTitle("GearCycler")]
-[assembly: AssemblyDescription("")]
-[assembly: AssemblyConfiguration("")]
-[assembly: AssemblyCompany("")]
-[assembly: AssemblyProduct("GearCycler")]
-[assembly: AssemblyCopyright("Copyright © 2025")]
-[assembly: AssemblyTrademark("")]
-[assembly: AssemblyCulture("")]
-
-// Setting ComVisible to false makes the types in this assembly not visible
-// to COM components. If you need to access a type in this assembly from
-// COM, set the ComVisible attribute to true on that type.
-[assembly: ComVisible(false)]
-
-// The following GUID is for the ID of the typelib if this project is exposed to COM
-[assembly: Guid("f5462318-d26a-4ab0-8981-80edd9ec9c99")]
-
-// Version information for an assembly consists of the following four values:
-//
-// Major Version
-// Minor Version
-// Build Number
-// Revision
-//
-[assembly: AssemblyVersion("1.0.0.0")]
-[assembly: AssemblyFileVersion("1.0.0.0")]
diff --git a/GearCycler/Properties/Resources.Designer.cs b/GearCycler/Properties/Resources.Designer.cs
deleted file mode 100644
index 40b9adf..0000000
--- a/GearCycler/Properties/Resources.Designer.cs
+++ /dev/null
@@ -1,63 +0,0 @@
-//------------------------------------------------------------------------------
-//
-// This code was generated by a tool.
-// Runtime Version:4.0.30319.42000
-//
-// Changes to this file may cause incorrect behavior and will be lost if
-// the code is regenerated.
-//
-//------------------------------------------------------------------------------
-
-namespace GearCycler.Properties {
- using System;
-
-
- ///
- /// A strongly-typed resource class, for looking up localized strings, etc.
- ///
- // This class was auto-generated by the StronglyTypedResourceBuilder
- // class via a tool like ResGen or Visual Studio.
- // To add or remove a member, edit your .ResX file then rerun ResGen
- // with the /str option, or rebuild your VS project.
- [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")]
- [global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
- [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
- internal class Resources {
-
- private static global::System.Resources.ResourceManager resourceMan;
-
- private static global::System.Globalization.CultureInfo resourceCulture;
-
- [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")]
- internal Resources() {
- }
-
- ///
- /// Returns the cached ResourceManager instance used by this class.
- ///
- [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
- internal static global::System.Resources.ResourceManager ResourceManager {
- get {
- if (object.ReferenceEquals(resourceMan, null)) {
- global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("GearCycler.Properties.Resources", typeof(Resources).Assembly);
- resourceMan = temp;
- }
- return resourceMan;
- }
- }
-
- ///
- /// Overrides the current thread's CurrentUICulture property for all
- /// resource lookups using this strongly typed resource class.
- ///
- [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
- internal static global::System.Globalization.CultureInfo Culture {
- get {
- return resourceCulture;
- }
- set {
- resourceCulture = value;
- }
- }
- }
-}
diff --git a/GearCycler/Properties/Resources.resx b/GearCycler/Properties/Resources.resx
deleted file mode 100644
index 4fdb1b6..0000000
--- a/GearCycler/Properties/Resources.resx
+++ /dev/null
@@ -1,101 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- text/microsoft-resx
-
-
- 1.3
-
-
- System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.3500.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
-
-
- System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.3500.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
-
-
\ No newline at end of file
diff --git a/GearCycler/ViewXML/mainView.xml b/GearCycler/ViewXML/mainView.xml
deleted file mode 100644
index 1300d02..0000000
--- a/GearCycler/ViewXML/mainView.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
diff --git a/GearCycler/lib/Decal.Adapter.dll b/GearCycler/lib/Decal.Adapter.dll
deleted file mode 100644
index c27e132..0000000
Binary files a/GearCycler/lib/Decal.Adapter.dll and /dev/null differ
diff --git a/GearCycler/lib/Decal.Interop.Core.DLL b/GearCycler/lib/Decal.Interop.Core.DLL
deleted file mode 100644
index b8de808..0000000
Binary files a/GearCycler/lib/Decal.Interop.Core.DLL and /dev/null differ
diff --git a/GearCycler/lib/Decal.Interop.Inject.dll b/GearCycler/lib/Decal.Interop.Inject.dll
deleted file mode 100644
index f186c93..0000000
Binary files a/GearCycler/lib/Decal.Interop.Inject.dll and /dev/null differ
diff --git a/GearCycler/lib/VirindiViewService.dll b/GearCycler/lib/VirindiViewService.dll
deleted file mode 100644
index 1e3f4fc..0000000
Binary files a/GearCycler/lib/VirindiViewService.dll and /dev/null differ
diff --git a/GearCycler/lib/VirindiViewService.xml b/GearCycler/lib/VirindiViewService.xml
deleted file mode 100644
index c43230e..0000000
--- a/GearCycler/lib/VirindiViewService.xml
+++ /dev/null
@@ -1,386 +0,0 @@
-
-
-
- VirindiViewService
-
-
-
-
- Implies Top and Left
-
-
-
-
- Provides theme elements, which can be drawn by controls.
-
-
-
-
- Displays an element from the current theme.
-
-
-
-
- The base class for all Virindi Views controls.
-
-
-
-
- Called after this control is added to a ControlGroup. This is when the Name and details have been set.
-
-
-
-
- Add and initialize a child control of this control. The child may be removed by disposing it.
-
-
-
-
-
- Called when a child of this control is disposed.
-
-
-
-
-
- Recursively disposes all children and removes this control from the view, if it is initialized.
-
-
-
-
- Handles a mouse wheel event. Parent controls must pass this on to applicable children if necessary.
-
-
-
-
-
-
- Fires the MouseEvent event for mouse down, and sets this control as the focus control if CanTakeFocus is true.
-
- Parent controls must pass this on to applicable children if necessary.
-
-
-
-
-
- Fires the MouseEvent event for mouse up as well as the Hit event.
-
- Parent controls must pass this on to applicable children if necessary.
-
-
-
-
-
-
- Fired when the mousedown originated outside the current view. The base version of this method
- passes on the event to all children if the 'up' point is within its saved rect.
-
- Mouseup point
-
-
-
- Tracks mouseover and fires the MouseOverChange event, as well as the MouseEvent event for mouse move.
-
- Parent controls must pass this on to applicable children if necessary.
-
-
-
-
-
- Parses a key message and fires the specific key event methods.
-
- Key events are only sent to the control with focus.
-
-
-
-
-
-
-
-
- WARNING: ONLY A PARENT CONTROL SHOULD CALL THIS METHOD.
-
- This method is overridden in derived controls to handle the actual control drawing. Overridden methods should call
- the base, draw, and recursively call this method on all child controls.
-
-
-
-
-
- WARNING: ONLY A PARENT CONTROL SHOULD CALL THIS METHOD.
-
- Notifies a control of changed saved draw options. This method saves its parameters in the Savedxxx properties.
- Parent controls should override this method and recursively notify children of their new draw options, altering
- their pClipRegion to reflect their new position in the View.
-
- This base method also fires the DrawStateChange and ThemeChanged events.
-
- This control's area, relative to the view area.
- The theme applied to this control.
- The context of this control, eg. inside a listbox.
- The position of the View, in game window coordinates.
-
-
-
- WARNING: ONLY A PARENT CONTROL SHOULD SET THIS PROPERTY.
-
-
-
-
- List of XmlAttributes present on the XmlNode that was used to construct this control, if the control was loaded from XML. Otherwise, empty.
-
-
-
-
- The XmlNode used to construct this control, if the control was loaded from XML. Otherwise, null.
-
-
-
-
- The name that this control will be initialized with.
-
-
-
-
- A multiline uneditable scrolling text box.
-
-
-
-
- A single image control.
-
-
-
-
- A button using custom images.
-
-
-
-
- A doubly-linked list with a Dictionary index. Duplicate items are not allowed.
- -Add is O(1)
- -Contains is O(1)
- -Remove is O(1)
- -Get/set by index is O(n)
- -Insert is O(n)
- -RemoveAt is O(n)
- Additionally, a cached pointer (with associated index) is kept pointing to the last used index item.
- When looking up an item by index, the list is walked from the head, tail, or cached index pointer.
- Thus, doing multiple operations in index order is O(1) even without an enumerator.
-
-
-
-
-
- This method gets the node corresponding to a particular index. To get there,
- the list is traversed from the head, tail, or cached index pointer (if valid).
-
-
-
-
-
-
- Web browser control, using Awesomium (free license version)
-
-
-
-
- A horizontal scrollbar.
-
-
-
-
- Summary description for ByteCursor.
-
-
-
-
- A checkbox with optional associated text. Uses its parent to provide the background.
-
-
-
-
- A single-line text input box.
-
-
-
-
- Called before render so the required size of the new target area can be calculated.
- The returned value is the size of the desired draw area, not including outer borders and
- style-dependent padding. This size must be less than or equal to MaximumSize in each dimension.
-
-
-
-
-
-
- Draw this element. When this is called, the background and borders will already have been drawn, and
- target will already be in BeginRender. This method should leave the target in render mode.
-
-
-
-
-
-
-
- A renderer for string-only tooltips.
-
-
-
-
- Represents an unordered set of items. Duplicates are not allowed.
- (This is really just a dictionary which only holds keys.)
- Should be used when a collection of non-duplicate items is needed and
- the order doesn't matter.
-
-
-
-
- A series of titled tabs along the top, each one having an associated control which appears
- on the bottom when its tab is enabled.
-
-
-
-
- A progressbar.
-
-
-
-
- A regular pushbutton-style control.
-
-
-
-
- Calls the non-hooked IDirect3DDevice9::BeginScene function. When rendering inside a VVS view or texture, use DxTexture.BeginRender() instead.
-
-
-
-
-
- Calls the non-hooked IDirect3DDevice9::EndScene function. When rendering inside a VVS view or texture, use DxTexture.EndRender() instead.
-
-
-
-
-
- Gets the current instance of the VVS bar.
-
-
-
-
- A console containing game chat.
-
-
-
-
- Initializes Direct3D drawing and sets the rendertarget to this texture. Calls to this method should be minimized to improve performance. DxTexture.EndRender() must be called after calling this method.
-
-
-
-
-
-
-
-
-
- Ends Direct3D rendering and resets the rendertarget. Must be called after DxTexture.BeginRender().
-
-
-
-
- Note: Before use, FlushSprite() may need to be called to ensure correct ordering.
-
-
-
-
-
-
-
-
- Note: Before use, you must call BeginUserDrawOperation().
-
-
-
-
-
-
-
-
- Note: Before use, you must call BeginUserDrawOperation().
-
-
-
-
-
-
-
-
-
- A vertically scrolling list, containing a number of rows and columns. Every row
- has the same number and types of columns. Each column contains a specified control type.
-
-
-
-
- A number of images on top of each other, which always draw in the proper order.
-
-
-
-
- A simple text display control. Uses its parent to provide the background.
-
-
-
-
- A container for multiple controls with set locations and sizes within.
-
-
-
-
- A dropdown list.
-
-
-
-
- If the context menu is not visible, it is created at the specified point.
-
-
-
-
- If the context menu is not visible, it is created at the specified point with the specified theme.
-
-
-
-
- Provides information about an associated tooltip.
-
-
-
-
- The HudControl that the tip is attached to.
-
-
-
-
- Deprecated.
- Returns the text associated with a tooltip only if the tip contains a cStringRenderer.
-
-
-
-
- A vertical scrollbar.
-
-
-
-
- A horizontal slider.
-
-
-
-
- A control that allows easy access to underlying draw methods.
-
-
-
-
diff --git a/GearCycler/mainView.xml b/GearCycler/mainView.xml
deleted file mode 100644
index d68a2a3..0000000
--- a/GearCycler/mainView.xml
+++ /dev/null
@@ -1,3 +0,0 @@
-
-
-
\ No newline at end of file
diff --git a/MosswartMassacre.Loader/LoaderCore.cs b/MosswartMassacre.Loader/LoaderCore.cs
new file mode 100644
index 0000000..4aae64c
--- /dev/null
+++ b/MosswartMassacre.Loader/LoaderCore.cs
@@ -0,0 +1,264 @@
+using System;
+using System.IO;
+using System.Reflection;
+using System.Runtime.InteropServices;
+using Microsoft.Win32;
+using Decal.Adapter;
+
+namespace MosswartMassacre.Loader
+{
+ [FriendlyName("MosswartMassacre.Loader")]
+ public class LoaderCore : FilterBase
+ {
+ private Assembly pluginAssembly;
+ private Type pluginType;
+ private object pluginInstance;
+ private FileSystemWatcher pluginWatcher;
+ private bool isSubscribedToRenderFrame = false;
+ private bool needsReload;
+
+ public static string PluginAssemblyNamespace => "MosswartMassacre";
+ public static string PluginAssemblyName => $"{PluginAssemblyNamespace}.dll";
+ public static string PluginAssemblyGuid => "{8C97E839-4D05-4A5F-B0C8-E8E778654322}";
+
+ public static bool IsPluginLoaded { get; private set; }
+
+ ///
+ /// Assembly directory (contains both loader and plugin dlls)
+ ///
+ public static string AssemblyDirectory => System.IO.Path.GetDirectoryName(Assembly.GetAssembly(typeof(LoaderCore)).Location);
+
+ public DateTime LastDllChange { get; private set; }
+
+ #region Event Handlers
+ protected override void Startup()
+ {
+ try
+ {
+ Core.PluginInitComplete += Core_PluginInitComplete;
+ Core.PluginTermComplete += Core_PluginTermComplete;
+ Core.FilterInitComplete += Core_FilterInitComplete;
+
+ // Set up assembly resolution for hot-loaded plugin dependencies
+ AppDomain.CurrentDomain.AssemblyResolve += CurrentDomain_AssemblyResolve;
+
+ // watch the AssemblyDirectory for any .dll file changes
+ pluginWatcher = new FileSystemWatcher();
+ pluginWatcher.Path = AssemblyDirectory;
+ pluginWatcher.NotifyFilter = NotifyFilters.LastWrite;
+ pluginWatcher.Filter = "*.dll";
+ pluginWatcher.Changed += PluginWatcher_Changed;
+ pluginWatcher.EnableRaisingEvents = true;
+ }
+ catch (Exception ex)
+ {
+ Log(ex);
+ }
+ }
+
+ private void Core_FilterInitComplete(object sender, EventArgs e)
+ {
+ Core.EchoFilter.ClientDispatch += EchoFilter_ClientDispatch;
+ }
+
+ private void EchoFilter_ClientDispatch(object sender, NetworkMessageEventArgs e)
+ {
+ try
+ {
+ // Login_SendEnterWorldRequest
+ if (e.Message.Type == 0xF7C8)
+ {
+ //EnsurePluginIsDisabledInRegistry();
+ }
+ }
+ catch (Exception ex)
+ {
+ Log(ex);
+ }
+ }
+
+ private void Core_PluginInitComplete(object sender, EventArgs e)
+ {
+ try
+ {
+ LoadPluginAssembly();
+ }
+ catch (Exception ex)
+ {
+ Log(ex);
+ }
+ }
+
+ private void Core_PluginTermComplete(object sender, EventArgs e)
+ {
+ try
+ {
+ UnloadPluginAssembly();
+ }
+ catch (Exception ex)
+ {
+ Log(ex);
+ }
+ }
+
+ protected override void Shutdown()
+ {
+ try
+ {
+ Core.PluginInitComplete -= Core_PluginInitComplete;
+ Core.PluginTermComplete -= Core_PluginTermComplete;
+ Core.FilterInitComplete -= Core_FilterInitComplete;
+ AppDomain.CurrentDomain.AssemblyResolve -= CurrentDomain_AssemblyResolve;
+ UnloadPluginAssembly();
+ }
+ catch (Exception ex)
+ {
+ Log(ex);
+ }
+ }
+
+ private void Core_RenderFrame(object sender, EventArgs e)
+ {
+ try
+ {
+ if (IsPluginLoaded && needsReload && DateTime.UtcNow - LastDllChange > TimeSpan.FromSeconds(1))
+ {
+ needsReload = false;
+ Core.RenderFrame -= Core_RenderFrame;
+ isSubscribedToRenderFrame = false;
+ LoadPluginAssembly();
+ }
+ }
+ catch (Exception ex)
+ {
+ Log(ex);
+ }
+ }
+
+ private void PluginWatcher_Changed(object sender, FileSystemEventArgs e)
+ {
+ try
+ {
+ // Only reload if it's the main plugin DLL
+ if (e.Name == PluginAssemblyName)
+ {
+ LastDllChange = DateTime.UtcNow;
+ needsReload = true;
+
+ if (!isSubscribedToRenderFrame)
+ {
+ isSubscribedToRenderFrame = true;
+ Core.RenderFrame += Core_RenderFrame;
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ Log(ex);
+ }
+ }
+
+ private Assembly CurrentDomain_AssemblyResolve(object sender, ResolveEventArgs args)
+ {
+ try
+ {
+ // Extract just the assembly name (without version info)
+ string assemblyName = args.Name.Split(',')[0] + ".dll";
+ string assemblyPath = System.IO.Path.Combine(AssemblyDirectory, assemblyName);
+
+ // If the dependency exists in our plugin directory, load it
+ if (File.Exists(assemblyPath))
+ {
+ return Assembly.LoadFrom(assemblyPath);
+ }
+ }
+ catch (Exception ex)
+ {
+ Log($"AssemblyResolve failed for {args.Name}: {ex.Message}");
+ }
+
+ // Return null to let default resolution continue
+ return null;
+ }
+ #endregion
+
+ #region Plugin Loading/Unloading
+ internal void LoadPluginAssembly()
+ {
+ try
+ {
+ if (IsPluginLoaded)
+ {
+ UnloadPluginAssembly();
+ try
+ {
+ CoreManager.Current.Actions.AddChatText($"[MosswartMassacre] Reloading {PluginAssemblyName}", 5);
+ }
+ catch { }
+ }
+
+ pluginAssembly = Assembly.Load(File.ReadAllBytes(System.IO.Path.Combine(AssemblyDirectory, PluginAssemblyName)));
+ pluginType = pluginAssembly.GetType($"{PluginAssemblyNamespace}.PluginCore");
+ pluginInstance = Activator.CreateInstance(pluginType);
+
+ // Set the AssemblyDirectory property if it exists
+ var assemblyDirProperty = pluginType.GetProperty("AssemblyDirectory", BindingFlags.Public | BindingFlags.Static);
+ assemblyDirProperty?.SetValue(null, AssemblyDirectory);
+
+ // Set the IsHotReload flag if it exists
+ var isHotReloadProperty = pluginType.GetProperty("IsHotReload", BindingFlags.Public | BindingFlags.Static);
+ isHotReloadProperty?.SetValue(null, true);
+
+ // The original template doesn't set up Host - it just calls Startup
+ // The plugin should use CoreManager.Current.Actions instead of MyHost for hot reload scenarios
+ // We'll set a flag so the plugin knows it's being hot loaded
+
+ // Call Startup method
+ var startupMethod = pluginType.GetMethod("Startup", BindingFlags.NonPublic | BindingFlags.Instance);
+ startupMethod.Invoke(pluginInstance, new object[] { });
+
+ IsPluginLoaded = true;
+ }
+ catch (Exception ex)
+ {
+ Log(ex);
+ }
+ }
+
+ private void UnloadPluginAssembly()
+ {
+ try
+ {
+ if (pluginInstance != null && pluginType != null)
+ {
+ MethodInfo shutdownMethod = pluginType.GetMethod("Shutdown", BindingFlags.NonPublic | BindingFlags.Instance);
+ shutdownMethod.Invoke(pluginInstance, null);
+ pluginInstance = null;
+ pluginType = null;
+ pluginAssembly = null;
+ }
+ IsPluginLoaded = false;
+ }
+ catch (Exception ex)
+ {
+ Log(ex);
+ }
+ }
+ #endregion
+
+ private void Log(Exception ex)
+ {
+ Log(ex.ToString());
+ }
+
+ private void Log(string message)
+ {
+ File.AppendAllText(System.IO.Path.Combine(AssemblyDirectory, "loader_log.txt"), $"{DateTime.Now:yyyy-MM-dd HH:mm:ss} - {message}\n");
+ try
+ {
+ CoreManager.Current.Actions.AddChatText($"[MosswartMassacre.Loader] {message}", 3);
+ }
+ catch { }
+ }
+ }
+}
\ No newline at end of file
diff --git a/MosswartMassacre.Loader/MosswartMassacre.Loader.csproj b/MosswartMassacre.Loader/MosswartMassacre.Loader.csproj
new file mode 100644
index 0000000..59c8e03
--- /dev/null
+++ b/MosswartMassacre.Loader/MosswartMassacre.Loader.csproj
@@ -0,0 +1,37 @@
+
+
+
+ net48
+ Library
+ true
+ x86
+ 1.0.0
+ 8
+ {A1B2C3D4-E5F6-7890-1234-567890ABCDEF}
+ MosswartMassacre.Loader
+ MosswartMassacre.Loader
+ true
+
+
+ ..\MosswartMassacre\bin\Debug\
+ true
+ full
+
+
+ ..\MosswartMassacre\bin\Release\
+ pdbonly
+ true
+
+
+
+ ..\MosswartMassacre\lib\Decal.Adapter.dll
+ False
+
+
+ False
+ False
+ ..\..\..\..\..\..\Program Files (x86)\Decal 3.0\.NET 4.0 PIA\Decal.Interop.Core.DLL
+ False
+
+
+
\ No newline at end of file
diff --git a/MosswartMassacre/CharacterStats.cs b/MosswartMassacre/CharacterStats.cs
new file mode 100644
index 0000000..5862dc2
--- /dev/null
+++ b/MosswartMassacre/CharacterStats.cs
@@ -0,0 +1,376 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Runtime.InteropServices;
+using Decal.Adapter;
+using Decal.Adapter.Wrappers;
+using Newtonsoft.Json;
+
+namespace MosswartMassacre
+{
+ public struct AllegianceInfoRecord
+ {
+ public string name;
+ public int rank;
+ public int race;
+ public int gender;
+
+ public AllegianceInfoRecord(string _name, int _rank, int _race, int _gender)
+ {
+ name = _name;
+ rank = _rank;
+ race = _race;
+ gender = _gender;
+ }
+ }
+
+ public static class CharacterStats
+ {
+ private static IPluginLogger _logger;
+
+ // Cached allegiance data (populated from network messages)
+ private static string allegianceName;
+ private static int allegianceSize;
+ private static int followers;
+ private static AllegianceInfoRecord monarch;
+ private static AllegianceInfoRecord patron;
+ private static int allegianceRank;
+
+ // Cached luminance data (populated from network messages)
+ private static long luminanceEarned = -1;
+ private static long luminanceTotal = -1;
+
+ // Cached title data (populated from network messages)
+ private static int currentTitle = -1;
+ private static List titlesList = new List();
+
+ // Cached DWORD properties (populated from 0x0013 message)
+ private static Dictionary characterProperties = new Dictionary();
+
+ // DWORD blacklist — exclude cosmetic/internal properties (same as TreeStats)
+ private static readonly HashSet dwordBlacklist = new HashSet {
+ 2, 5, 7, 10, 17, 19, 20, 24, 25, 26, 28, 30, 33, 35, 36, 38, 43, 45,
+ 86, 87, 88, 89, 90, 91, 92, 98,
+ 105, 106, 107, 108, 109, 110, 111, 113, 114, 115, 117, 125, 129, 131, 134,
+ 158, 159, 160, 166, 170, 171, 172, 174, 175, 176, 177, 178, 179, 188, 193,
+ 270, 271, 272, 293
+ };
+
+ ///
+ /// Reset all cached data. Call on plugin init.
+ ///
+ internal static void Init(IPluginLogger logger = null)
+ {
+ _logger = logger;
+ allegianceName = null;
+ allegianceSize = 0;
+ followers = 0;
+ monarch = new AllegianceInfoRecord();
+ patron = new AllegianceInfoRecord();
+ allegianceRank = 0;
+ luminanceEarned = -1;
+ luminanceTotal = -1;
+ currentTitle = -1;
+ titlesList.Clear();
+ characterProperties.Clear();
+ }
+
+ ///
+ /// Process game event 0x0020 - Allegiance info.
+ /// Extracts monarch, patron, rank, followers from the allegiance tree.
+ /// Reference: TreeStats Character.cs:642-745
+ ///
+ internal static void ProcessAllegianceInfoMessage(NetworkMessageEventArgs e)
+ {
+ try
+ {
+ allegianceName = e.Message.Value("allegianceName");
+ allegianceSize = e.Message.Value("allegianceSize");
+ followers = e.Message.Value("followers");
+
+ monarch = new AllegianceInfoRecord();
+ patron = new AllegianceInfoRecord();
+
+ MessageStruct records = e.Message.Struct("records");
+ int currentId = CoreManager.Current.CharacterFilter.Id;
+ var parentMap = new Dictionary();
+ var recordMap = new Dictionary();
+
+ for (int i = 0; i < records.Count; i++)
+ {
+ var record = records.Struct(i);
+ int charId = record.Value("character");
+ int treeParent = record.Value("treeParent");
+
+ parentMap[charId] = treeParent;
+ recordMap[charId] = new AllegianceInfoRecord(
+ record.Value("name"),
+ record.Value("rank"),
+ record.Value("race"),
+ record.Value("gender"));
+
+ // Monarch: treeParent <= 1
+ if (treeParent <= 1)
+ {
+ monarch = recordMap[charId];
+ }
+ }
+
+ // Patron: parent of current character
+ if (parentMap.ContainsKey(currentId) && recordMap.ContainsKey(parentMap[currentId]))
+ {
+ patron = recordMap[parentMap[currentId]];
+ }
+
+ // Our rank from the record
+ if (recordMap.ContainsKey(currentId))
+ {
+ allegianceRank = recordMap[currentId].rank;
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger?.Log($"[CharStats] Allegiance processing error: {ex.Message}");
+ }
+ }
+
+ ///
+ /// Process game event 0x0013 - Character property data.
+ /// Extracts luminance from QWORD keys 6 and 7.
+ /// Reference: TreeStats Character.cs:582-640
+ ///
+ internal static void ProcessCharacterPropertyData(NetworkMessageEventArgs e)
+ {
+ try
+ {
+ MessageStruct props = e.Message.Struct("properties");
+ MessageStruct qwords = props.Struct("qwords");
+
+ for (int i = 0; i < qwords.Count; i++)
+ {
+ var tmpStruct = qwords.Struct(i);
+ long key = tmpStruct.Value("key");
+ long value = tmpStruct.Value("value");
+
+ if (key == Constants.AvailableLuminanceKey)
+ luminanceEarned = value;
+ else if (key == Constants.MaximumLuminanceKey)
+ luminanceTotal = value;
+ }
+
+ // Parse DWORD properties (augmentations, ratings, masteries, society, etc.)
+ MessageStruct dwords = props.Struct("dwords");
+ characterProperties.Clear();
+ for (int i = 0; i < dwords.Count; i++)
+ {
+ var tmpStruct = dwords.Struct(i);
+ int key = tmpStruct.Value("key");
+ int value = tmpStruct.Value("value");
+ if (!dwordBlacklist.Contains(key))
+ characterProperties[key] = value;
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger?.Log($"[CharStats] Property processing error: {ex.Message}");
+ }
+ }
+
+ ///
+ /// Process message 0x02CF - PrivateUpdatePropertyInt64.
+ /// Sent during gameplay when an Int64 property changes (e.g., luminance earned/spent).
+ /// Wire format after 4-byte type header: sequence(1) + key(4) + value(8).
+ /// Uses RawData since Decal's messages.xml may not define this message type.
+ ///
+ internal static void ProcessPropertyInt64Update(NetworkMessageEventArgs e)
+ {
+ try
+ {
+ byte[] raw = e.Message.RawData;
+ if (raw.Length < 17) return; // 4 type + 1 seq + 4 key + 8 value
+
+ int key = BitConverter.ToInt32(raw, 5);
+ long value = BitConverter.ToInt64(raw, 9);
+
+ if (key == Constants.AvailableLuminanceKey)
+ luminanceEarned = value;
+ else if (key == Constants.MaximumLuminanceKey)
+ luminanceTotal = value;
+ }
+ catch (Exception ex)
+ {
+ _logger?.Log($"[CharStats] Int64 property update error: {ex.Message}");
+ }
+ }
+
+ ///
+ /// Process game event 0x0029 - Titles list.
+ /// Extracts current title ID.
+ /// Reference: TreeStats Character.cs:551-580
+ ///
+ internal static void ProcessTitlesMessage(NetworkMessageEventArgs e)
+ {
+ try
+ {
+ // Capture full titles list
+ MessageStruct titles = e.Message.Struct("titles");
+ titlesList.Clear();
+ for (int i = 0; i < titles.Count; i++)
+ {
+ titlesList.Add(titles.Struct(i).Value("value"));
+ }
+
+ currentTitle = e.Message.Value("current");
+ }
+ catch (Exception ex)
+ {
+ _logger?.Log($"[CharStats] Title processing error: {ex.Message}");
+ }
+ }
+
+ ///
+ /// Process game event 0x002b - Set title (when player changes title).
+ ///
+ internal static void ProcessSetTitleMessage(NetworkMessageEventArgs e)
+ {
+ try
+ {
+ currentTitle = e.Message.Value("title");
+ }
+ catch (Exception ex)
+ {
+ _logger?.Log($"[CharStats] Set title error: {ex.Message}");
+ }
+ }
+
+ ///
+ /// Collect all character data and send via WebSocket.
+ /// Called on login (after delay) and every 10 minutes.
+ ///
+ internal static void CollectAndSend()
+ {
+ if (!PluginCore.WebSocketEnabled)
+ return;
+
+ try
+ {
+ var cf = CoreManager.Current.CharacterFilter;
+ var culture = new CultureInfo("en-US");
+
+ // --- Attributes ---
+ var attributes = new Dictionary();
+ foreach (var attr in cf.Attributes)
+ {
+ attributes[attr.Name.ToLower()] = new
+ {
+ @base = attr.Base,
+ creation = attr.Creation
+ };
+ }
+
+ // --- Vitals (base values) ---
+ var vitals = new Dictionary();
+ foreach (var vital in cf.Vitals)
+ {
+ vitals[vital.Name.ToLower()] = new
+ {
+ @base = vital.Base
+ };
+ }
+
+ // --- Skills ---
+ var skills = new Dictionary();
+ Decal.Filters.FileService fs = CoreManager.Current.FileService as Decal.Filters.FileService;
+ if (fs != null)
+ {
+ for (int i = 0; i < fs.SkillTable.Length; i++)
+ {
+ Decal.Interop.Filters.SkillInfo skillinfo = null;
+ try
+ {
+ skillinfo = cf.Underlying.get_Skill(
+ (Decal.Interop.Filters.eSkillID)fs.SkillTable[i].Id);
+
+ string name = skillinfo.Name.ToLower().Replace(" ", "_");
+ string training = skillinfo.Training.ToString();
+ // Training enum returns "eTrainSpecialized" etc, strip "eTrain" prefix
+ if (training.Length > 6)
+ training = training.Substring(6);
+
+ skills[name] = new
+ {
+ @base = skillinfo.Base,
+ training = training
+ };
+ }
+ finally
+ {
+ if (skillinfo != null)
+ {
+ Marshal.ReleaseComObject(skillinfo);
+ skillinfo = null;
+ }
+ }
+ }
+ }
+
+ // --- Allegiance ---
+ object allegiance = null;
+ if (allegianceName != null)
+ {
+ allegiance = new
+ {
+ name = allegianceName,
+ monarch = monarch.name != null ? new
+ {
+ name = monarch.name,
+ race = monarch.race,
+ rank = monarch.rank,
+ gender = monarch.gender
+ } : null,
+ patron = patron.name != null ? new
+ {
+ name = patron.name,
+ race = patron.race,
+ rank = patron.rank,
+ gender = patron.gender
+ } : null,
+ rank = allegianceRank,
+ followers = followers
+ };
+ }
+
+ // --- Build payload ---
+ var payload = new
+ {
+ type = "character_stats",
+ timestamp = DateTime.UtcNow.ToString("o"),
+ character_name = cf.Name,
+ level = cf.Level,
+ race = cf.Race,
+ gender = cf.Gender,
+ birth = cf.Birth.ToString(culture),
+ total_xp = cf.TotalXP,
+ unassigned_xp = cf.UnassignedXP,
+ skill_credits = cf.SkillPoints,
+ deaths = cf.Deaths,
+ luminance_earned = luminanceEarned >= 0 ? (long?)luminanceEarned : null,
+ luminance_total = luminanceTotal >= 0 ? (long?)luminanceTotal : null,
+ current_title = currentTitle >= 0 ? (int?)currentTitle : null,
+ attributes = attributes,
+ vitals = vitals,
+ skills = skills,
+ allegiance = allegiance,
+ properties = characterProperties.Count > 0 ? new Dictionary(characterProperties) : null,
+ titles = titlesList.Count > 0 ? new List(titlesList) : null
+ };
+
+ _ = WebSocket.SendCharacterStatsAsync(payload);
+ }
+ catch (Exception ex)
+ {
+ _logger?.Log($"[CharStats] Error collecting stats: {ex.Message}");
+ }
+ }
+ }
+}
diff --git a/MosswartMassacre/ChatEventRouter.cs b/MosswartMassacre/ChatEventRouter.cs
new file mode 100644
index 0000000..9729ffa
--- /dev/null
+++ b/MosswartMassacre/ChatEventRouter.cs
@@ -0,0 +1,90 @@
+using System;
+using System.Text.RegularExpressions;
+using Decal.Adapter;
+using Decal.Adapter.Wrappers;
+
+namespace MosswartMassacre
+{
+ ///
+ /// Routes chat events to the appropriate handler (KillTracker, RareTracker, etc.)
+ /// Replaces the big if/else chain in PluginCore.OnChatText.
+ ///
+ internal class ChatEventRouter
+ {
+ private readonly IPluginLogger _logger;
+ private readonly KillTracker _killTracker;
+ private RareTracker _rareTracker;
+ private readonly Action _onRareCountChanged;
+ private readonly Action _onAllegianceReport;
+
+ internal void SetRareTracker(RareTracker rareTracker) => _rareTracker = rareTracker;
+
+ internal ChatEventRouter(
+ IPluginLogger logger,
+ KillTracker killTracker,
+ RareTracker rareTracker,
+ Action onRareCountChanged,
+ Action onAllegianceReport)
+ {
+ _logger = logger;
+ _killTracker = killTracker;
+ _rareTracker = rareTracker;
+ _onRareCountChanged = onRareCountChanged;
+ _onAllegianceReport = onAllegianceReport;
+ }
+
+ internal void OnChatText(object sender, ChatTextInterceptEventArgs e)
+ {
+ try
+ {
+ _killTracker.CheckForKill(e.Text);
+
+ if (_rareTracker != null && _rareTracker.CheckForRare(e.Text, out string rareText))
+ {
+ _killTracker.RareCount = _rareTracker.RareCount;
+ _onRareCountChanged?.Invoke(_rareTracker.RareCount);
+ }
+
+ if (e.Color == 18 && e.Text.EndsWith("!report\""))
+ {
+ TimeSpan elapsed = DateTime.Now - _killTracker.StatsStartTime;
+ string reportMessage = $"Total Kills: {_killTracker.TotalKills}, Kills per Hour: {_killTracker.KillsPerHour:F2}, Elapsed Time: {elapsed:dd\\.hh\\:mm\\:ss}, Rares Found: {_rareTracker?.RareCount ?? 0}";
+ _logger?.Log($"[Mosswart Massacre] Reporting to allegiance: {reportMessage}");
+ _onAllegianceReport?.Invoke(reportMessage);
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger?.Log("Error processing chat message: " + ex.Message);
+ }
+ }
+
+ ///
+ /// Streams all chat text to WebSocket (separate handler from the filtered one above).
+ ///
+ internal static async void AllChatText(object sender, ChatTextInterceptEventArgs e)
+ {
+ try
+ {
+ string cleaned = NormalizeChatLine(e.Text);
+ await WebSocket.SendChatTextAsync(e.Color, cleaned);
+ }
+ catch (Exception ex)
+ {
+ PluginCore.WriteToChat($"[WS] Chat send failed: {ex}");
+ }
+ }
+
+ private static string NormalizeChatLine(string raw)
+ {
+ if (string.IsNullOrEmpty(raw))
+ return raw;
+
+ var noTags = Regex.Replace(raw, "<[^>]+>", "");
+ var trimmed = noTags.TrimEnd('\r', '\n');
+ var collapsed = Regex.Replace(trimmed, @"[ ]{2,}", " ");
+
+ return collapsed;
+ }
+ }
+}
diff --git a/MosswartMassacre/ChestLooter.cs b/MosswartMassacre/ChestLooter.cs
new file mode 100644
index 0000000..a91c619
--- /dev/null
+++ b/MosswartMassacre/ChestLooter.cs
@@ -0,0 +1,1346 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Decal.Adapter;
+using Decal.Adapter.Wrappers;
+using Decal.Interop.Input;
+using Mag.Shared.Constants;
+using Timer = Decal.Interop.Input.Timer;
+
+namespace MosswartMassacre
+{
+ ///
+ /// Chest Looter - Automated chest looting with VTank loot profile integration
+ /// Ported from Utility Belt with enhancements for name-based chest/key selection
+ ///
+ public class ChestLooter : IDisposable
+ {
+ #region Enums
+
+ private enum ItemState
+ {
+ None = 0x0001,
+ InContainer = 0x0002,
+ RequestingInfo = 0x0004,
+ NeedsToBeLooted = 0x0008,
+ Ignore = 0x0016,
+ Looted = 0x0032,
+ Blacklisted = 0x0064
+ }
+
+ private enum LooterState
+ {
+ Closed = 0x0001,
+ Unlocking = 0x0002,
+ Locked = 0x0004,
+ Unlocked = 0x0008,
+ Opening = 0x00016,
+ Open = 0x0032,
+ Looting = 0x0064,
+ Closing = 0x0128,
+ Salvaging = 0x0256,
+ Done = 0x0512
+ }
+
+ public enum RunType
+ {
+ None = 0x0001,
+ CommandLine = 0x0002, // Started via /mm commands
+ UI = 0x0004 // Started via UI button
+ }
+
+ private enum ContainerType
+ {
+ Chest = 0x0001,
+ MyCorpse = 0x0002,
+ MonsterCorpse = 0x0004,
+ Unknown = 0x0008
+ }
+
+ #endregion
+
+ #region Fields
+
+ private readonly CoreManager core;
+ private readonly ChestLooterSettings settings;
+
+ private Dictionary containerItems = new Dictionary();
+ private List containerItemsList = new List();
+
+ private bool dispatchEnabled = false;
+ private bool ubOwnedContainer = false;
+ private bool first = true;
+
+ private int targetContainerID = 0;
+ private int targetKeyID = 0;
+ private string targetKeyName = "";
+ private string targetChestName = "";
+
+ private int lootAttemptCount = 0;
+ private int unlockAttempt = 0;
+ private int openAttempt = 0;
+ private int lastOpenContainer = 0;
+
+ private LooterState looterState = LooterState.Done;
+ private LooterState lastLooterState = LooterState.Done;
+ private RunType runType = RunType.None;
+ private ContainerType containerType = ContainerType.Unknown;
+
+ private DateTime startTime = DateTime.MinValue;
+ private DateTime lastAttempt = DateTime.UtcNow;
+
+ private TimerClass baseTimer;
+ private bool disposed = false;
+
+ #endregion
+
+ #region Events
+
+ public event EventHandler LooterStarted;
+ public event EventHandler LooterFinished;
+ public event EventHandler LooterFinishedForceStop;
+ public event EventHandler StatusChanged;
+
+ #endregion
+
+ #region Properties
+
+ public bool IsRunning => looterState != LooterState.Done;
+ public RunType CurrentRunType => runType;
+ public string CurrentStatus => GetStatusString();
+
+ #endregion
+
+ #region Constructor & Initialization
+
+ public ChestLooter(CoreManager coreManager, ChestLooterSettings chestLooterSettings)
+ {
+ core = coreManager ?? throw new ArgumentNullException(nameof(coreManager));
+ settings = chestLooterSettings ?? throw new ArgumentNullException(nameof(chestLooterSettings));
+
+ settings.Validate();
+ }
+
+ public void Initialize()
+ {
+ try
+ {
+ if (settings.Enabled)
+ {
+ EnableDispatch();
+ }
+ }
+ catch (Exception ex)
+ {
+ LogError($"ChestLooter initialization error: {ex.Message}");
+ }
+ }
+
+ #endregion
+
+ #region Public Methods
+
+ ///
+ /// Start looting using configured chest and key names
+ ///
+ public bool StartByName()
+ {
+ return StartByName(settings.ChestName, settings.KeyName);
+ }
+
+ ///
+ /// Start looting with specified chest and key names
+ ///
+ public bool StartByName(string chestName, string keyName)
+ {
+ try
+ {
+ if (IsRunning)
+ {
+ LogError("Looter is already running. Stop it first.");
+ return false;
+ }
+
+ if (string.IsNullOrEmpty(chestName))
+ {
+ LogError("Chest name not set. Use /mm setchest ");
+ return false;
+ }
+
+ if (string.IsNullOrEmpty(keyName))
+ {
+ LogError("Key name not set. Use /mm setkey ");
+ return false;
+ }
+
+ // Find the closest chest by name
+ var chest = Utils.FindClosestChestByName(chestName);
+ if (chest == null)
+ {
+ LogError($"Could not find chest named '{chestName}'");
+ return false;
+ }
+
+ // Find the key in inventory
+ var key = Utils.FindKeyInInventory(keyName);
+ if (key == null)
+ {
+ LogError($"Could not find key named '{keyName}' in inventory");
+ return false;
+ }
+
+ targetChestName = chestName;
+ targetKeyName = keyName;
+ targetContainerID = chest.Id;
+ targetKeyID = key.Id;
+
+ Log($"Starting chest looter: chest='{chest.Name}' key='{key.Name}'");
+
+ return StartInternal(RunType.CommandLine);
+ }
+ catch (Exception ex)
+ {
+ LogError($"Error starting looter by name: {ex.Message}");
+ return false;
+ }
+ }
+
+ ///
+ /// Start looting with manually selected chest and key
+ ///
+ public bool StartWithSelection(int chestId, string keyName)
+ {
+ try
+ {
+ if (IsRunning)
+ {
+ LogError("Looter is already running. Stop it first.");
+ return false;
+ }
+
+ if (!core.Actions.IsValidObject(chestId))
+ {
+ LogError("Invalid chest selection");
+ return false;
+ }
+
+ var key = Utils.FindKeyInInventory(keyName);
+ if (key == null)
+ {
+ LogError($"Could not find key named '{keyName}' in inventory");
+ return false;
+ }
+
+ targetContainerID = chestId;
+ targetKeyID = key.Id;
+ targetKeyName = keyName;
+
+ return StartInternal(RunType.UI);
+ }
+ catch (Exception ex)
+ {
+ LogError($"Error starting looter with selection: {ex.Message}");
+ return false;
+ }
+ }
+
+ ///
+ /// Stop the looter
+ ///
+ public void Stop()
+ {
+ try
+ {
+ if (!IsRunning)
+ return;
+
+ Log("Stopping looter...");
+ DoneLooting(false, true);
+ }
+ catch (Exception ex)
+ {
+ LogError($"Error stopping looter: {ex.Message}");
+ }
+ }
+
+ ///
+ /// Set the chest name for next run
+ ///
+ public void SetChestName(string name)
+ {
+ settings.ChestName = name;
+ Log($"Chest name set to: {name}");
+ }
+
+ ///
+ /// Set the key name for next run
+ ///
+ public void SetKeyName(string name)
+ {
+ settings.KeyName = name;
+ Log($"Key name set to: {name}");
+ }
+
+ #endregion
+
+ #region Private Core Methods
+
+ private bool StartInternal(RunType type)
+ {
+ try
+ {
+ looterState = LooterState.Unlocking;
+ lastAttempt = DateTime.UtcNow;
+ runType = type;
+ ubOwnedContainer = true;
+ startTime = DateTime.UtcNow;
+
+ EnableDispatch();
+ EnableBaseTimer();
+ LockVtankSettings(); // Lock VTank to prevent interference
+
+ LooterStarted?.Invoke(this, EventArgs.Empty);
+ UpdateStatus("Starting looter...");
+
+ return true;
+ }
+ catch (Exception ex)
+ {
+ LogError($"Error in StartInternal: {ex.Message}");
+ return false;
+ }
+ }
+
+ private void EnableDispatch()
+ {
+ try
+ {
+ if (!dispatchEnabled)
+ {
+ core.EchoFilter.ServerDispatch += EchoFilter_ServerDispatch;
+ core.EchoFilter.ClientDispatch += EchoFilter_ClientDispatch;
+ dispatchEnabled = true;
+ }
+ }
+ catch (Exception ex)
+ {
+ LogError($"Error enabling dispatch: {ex.Message}");
+ }
+ }
+
+ private void DisableDispatch()
+ {
+ try
+ {
+ if (dispatchEnabled && !settings.Enabled)
+ {
+ core.EchoFilter.ServerDispatch -= EchoFilter_ServerDispatch;
+ core.EchoFilter.ClientDispatch -= EchoFilter_ClientDispatch;
+ dispatchEnabled = false;
+ }
+ }
+ catch (Exception ex)
+ {
+ LogError($"Error disabling dispatch: {ex.Message}");
+ }
+ }
+
+ private void EnableBaseTimer()
+ {
+ try
+ {
+ if (baseTimer == null)
+ {
+ baseTimer = new TimerClass();
+ baseTimer.Timeout += BaseTimer_Timeout;
+ baseTimer.Start(settings.OverallSpeed);
+ }
+ }
+ catch (Exception ex)
+ {
+ LogError($"Error enabling base timer: {ex.Message}");
+ }
+ }
+
+ private void DisableBaseTimer()
+ {
+ try
+ {
+ if (baseTimer != null)
+ {
+ baseTimer.Timeout -= BaseTimer_Timeout;
+ baseTimer.Stop();
+ baseTimer = null;
+ }
+ }
+ catch (Exception ex)
+ {
+ LogError($"Error disabling base timer: {ex.Message}");
+ }
+ }
+
+ private void DoneLooting(bool writeChat = true, bool force = false)
+ {
+ try
+ {
+ var itemsInContainer = containerItems.Where(x => x.Value == ItemState.InContainer);
+ var needsToBeLootedItems = containerItems.Where(x => x.Value == ItemState.NeedsToBeLooted);
+ var lootedItems = containerItems.Where(x => x.Value == ItemState.Looted);
+ var blacklistedItems = containerItems.Where(x => x.Value == ItemState.Blacklisted);
+ var ignoredItems = containerItems.Where(x => x.Value == ItemState.Ignore);
+ var requestingInfoItems = containerItems.Where(x => x.Value == ItemState.RequestingInfo);
+
+ if (writeChat)
+ {
+ var elapsed = DateTime.UtcNow - startTime;
+ var testMode = settings.TestMode ? " [TEST MODE] " : "";
+
+ if (needsToBeLootedItems.Count() + blacklistedItems.Count() <= 0)
+ {
+ Log($"Looter{testMode} completed in {elapsed.TotalSeconds:F1}s - Scanned {containerItems.Count} items, looted {lootedItems.Count()} items");
+ }
+ else
+ {
+ Log($"Looter{testMode} completed in {elapsed.TotalSeconds:F1}s - Scanned {containerItems.Count} items, looted {lootedItems.Count()} items - Failed to loot {needsToBeLootedItems.Count() + blacklistedItems.Count()} items");
+ }
+ }
+
+ // Unlock VTank FIRST (like UB does)
+ UnlockVtankSettings();
+
+ // Reset state (matching UB exactly)
+ containerItems.Clear();
+ containerItemsList.Clear();
+ looterState = LooterState.Done;
+ lastLooterState = LooterState.Done;
+ ubOwnedContainer = false;
+ lootAttemptCount = 0;
+ unlockAttempt = 0;
+ openAttempt = 0;
+ targetKeyID = 0;
+ containerType = ContainerType.Unknown;
+ startTime = DateTime.MinValue;
+ lastAttempt = DateTime.UtcNow;
+
+ // FIXED: Check force FIRST - skip restart entirely if force stopping
+ if (force)
+ {
+ // Force stop - don't restart, just cleanup
+ LooterFinishedForceStop?.Invoke(this, EventArgs.Empty);
+ targetKeyName = "";
+ targetChestName = "";
+ targetContainerID = 0;
+ runType = RunType.None;
+ DisableBaseTimer();
+ if (!settings.Enabled && dispatchEnabled)
+ {
+ DisableDispatch();
+ }
+ UpdateStatus("Ready");
+ return; // Exit early - don't check for more keys
+ }
+
+ // Not force - check if we should restart with more keys
+ if ((runType == RunType.UI || runType == RunType.CommandLine) && HasMoreKeys(targetKeyName))
+ {
+ // Restart the whole process like UB does with StartUI
+ StartUI(targetContainerID, targetKeyName);
+ }
+ else
+ {
+ // No more keys - cleanup
+ targetKeyName = "";
+ targetChestName = "";
+ targetContainerID = 0;
+ runType = RunType.None;
+ DisableBaseTimer();
+ if (!settings.Enabled && dispatchEnabled)
+ {
+ DisableDispatch();
+ }
+ UpdateStatus("Ready");
+ LooterFinished?.Invoke(this, EventArgs.Empty);
+ }
+ }
+ catch (Exception ex)
+ {
+ LogError($"Error in DoneLooting: {ex.Message}");
+ }
+ }
+
+ ///
+ /// Start looting via UI/CommandLine - matches UB's StartUI method
+ ///
+ private void StartUI(int container, string key)
+ {
+ try
+ {
+ targetContainerID = container;
+ targetKeyName = key;
+
+ // Find key by EXACT name match (like UB)
+ var keyObj = FindKeyByExactName(key);
+ if (keyObj == null)
+ {
+ Log("Looter: Out of keys to use");
+ LooterFinished?.Invoke(this, EventArgs.Empty);
+ DoneLooting(false, true);
+ return;
+ }
+
+ targetKeyID = keyObj.Id;
+
+ looterState = LooterState.Unlocking;
+ lastAttempt = DateTime.UtcNow;
+ // Keep runType as is (UI or CommandLine)
+ ubOwnedContainer = true;
+ first = true;
+ unlockAttempt = 0;
+ openAttempt = 0;
+
+ EnableDispatch();
+ EnableBaseTimer();
+ }
+ catch (Exception ex)
+ {
+ LogError($"Error in StartUI: {ex.Message}");
+ }
+ }
+
+ ///
+ /// Find a key by EXACT name match (like UB's Util.FindInventoryObjectByName)
+ ///
+ private WorldObject FindKeyByExactName(string name)
+ {
+ try
+ {
+ using (var inv = core.WorldFilter.GetInventory())
+ {
+ foreach (var wo in inv)
+ {
+ if (wo.Name == name)
+ {
+ return wo;
+ }
+ }
+ }
+ return null;
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ private void StopAndCleanup()
+ {
+ targetKeyName = "";
+ targetChestName = "";
+ targetContainerID = 0;
+ runType = RunType.None;
+
+ DisableBaseTimer();
+ DisableDispatch();
+
+ LooterFinished?.Invoke(this, EventArgs.Empty);
+ UpdateStatus("Ready");
+ }
+
+ ///
+ /// Lock VTank to prevent it from interfering with looting (matches UB's LockVtankSettings)
+ ///
+ private void LockVtankSettings()
+ {
+ try
+ {
+ if (vTank.Instance == null) return;
+
+ // Lock navigation to prevent moving away from chest
+ vTank.Decision_Lock(uTank2.ActionLockType.Navigation, TimeSpan.FromMilliseconds(30000));
+ // Lock void spells (debuffs)
+ vTank.Decision_Lock(uTank2.ActionLockType.VoidSpellLockedOut, TimeSpan.FromMilliseconds(30000));
+ // Lock war spells (attacks)
+ vTank.Decision_Lock(uTank2.ActionLockType.WarSpellLockedOut, TimeSpan.FromMilliseconds(30000));
+ // Lock melee attacks
+ vTank.Decision_Lock(uTank2.ActionLockType.MeleeAttackShot, TimeSpan.FromMilliseconds(30000));
+ // Lock item use to prevent VTank from using items
+ vTank.Decision_Lock(uTank2.ActionLockType.ItemUse, TimeSpan.FromMilliseconds(30000));
+ }
+ catch (Exception ex)
+ {
+ LogError($"Error locking VTank: {ex.Message}");
+ }
+ }
+
+ ///
+ /// Unlock VTank after looting is complete (matches UB's UnLockVtankSettings)
+ ///
+ private void UnlockVtankSettings()
+ {
+ try
+ {
+ if (vTank.Instance == null) return;
+
+ vTank.Decision_UnLock(uTank2.ActionLockType.Navigation);
+ vTank.Decision_UnLock(uTank2.ActionLockType.VoidSpellLockedOut);
+ vTank.Decision_UnLock(uTank2.ActionLockType.WarSpellLockedOut);
+ vTank.Decision_UnLock(uTank2.ActionLockType.MeleeAttackShot);
+ vTank.Decision_UnLock(uTank2.ActionLockType.ItemUse);
+ }
+ catch (Exception ex)
+ {
+ LogError($"Error unlocking VTank: {ex.Message}");
+ }
+ }
+
+ #endregion
+
+ #region Network Event Handlers
+
+ private void EchoFilter_ServerDispatch(object sender, NetworkMessageEventArgs e)
+ {
+ try
+ {
+ switch (e.Message.Type)
+ {
+ case 0xF7B0:
+ switch (e.Message.Value("event"))
+ {
+ case 0x0196: // Item_OnViewContents - chest opened
+ HandleChestOpened(e);
+ break;
+
+ case 0x01C7: // Item_UseDone (Failure Type)
+ if (e.Message.Value("unknown") == 1201) // Already open
+ {
+ if (core.Actions.OpenedContainer == targetContainerID)
+ {
+ looterState = LooterState.Open;
+ }
+ }
+ break;
+
+ case 0x0022: // Item_ServerSaysContainID (moved object)
+ int movedObjectId = e.Message.Value("item");
+ int movedContainerId = e.Message.Value("container");
+ if (containerItems.ContainsKey(movedObjectId) && IsContainerPlayer(movedContainerId))
+ {
+ LootedItem(movedObjectId);
+ }
+ break;
+ }
+ break;
+
+ case 0x0024: // Object deleted (stacked item looted)
+ int removedObjectId = e.Message.Value("object");
+ LootedItem(removedObjectId);
+ break;
+
+ case 0xF750: // Chest unlocked/locked
+ HandleChestLockState(e);
+ break;
+ }
+ }
+ catch (Exception ex)
+ {
+ LogError($"Error in ServerDispatch: {ex.Message}");
+ }
+ }
+
+ private void EchoFilter_ClientDispatch(object sender, NetworkMessageEventArgs e)
+ {
+ try
+ {
+ switch (e.Message.Type)
+ {
+ case 0xF7B1:
+ switch (e.Message.Value("action"))
+ {
+ case 0x0019: // Item moved into container
+ int itemIntoContainer = e.Message.Value("item");
+ if (containerItems.ContainsKey(itemIntoContainer))
+ {
+ lootAttemptCount++;
+ }
+ break;
+ }
+ break;
+ }
+ }
+ catch (Exception ex)
+ {
+ LogError($"Error in ClientDispatch: {ex.Message}");
+ }
+ }
+
+ private void HandleChestOpened(NetworkMessageEventArgs e)
+ {
+ try
+ {
+ // Match UB's condition exactly: if (Enabled || runType == runtype.UI)
+ // For us: settings.Enabled OR runType is UI/CommandLine
+ if (settings.Enabled || runType == RunType.UI || runType == RunType.CommandLine)
+ {
+ int containerID = e.Message.Value("container");
+ var itemCount = e.Message.Value("itemCount");
+ var items = e.Message.Struct("items");
+
+ if (itemCount <= 0) return;
+ if (containerID == 0) return;
+ if (!IsValidContainer(containerID)) return;
+
+ containerType = GetContainerType(containerID);
+
+ // Return if container isn't enabled in settings
+ if (containerType == ContainerType.Unknown) return;
+ // EnableChests check only when NOT running via UI/CommandLine
+ if (containerType == ContainerType.Chest && !settings.EnableChests && runType != RunType.UI && runType != RunType.CommandLine) return;
+
+ // Past returns, now we do things
+ startTime = DateTime.UtcNow;
+ targetContainerID = containerID;
+
+ // When we receive chest contents, the chest is open - transition to Open state
+ // UB relies on HandleStateOpening checking OpenedContainer, but we can be more direct
+ if (looterState != LooterState.Looting)
+ {
+ looterState = LooterState.Open;
+ }
+
+ // Add all items in chest to tracking
+ for (int i = 0; i < itemCount; i++)
+ {
+ int woid = items.Struct(i).Value("item");
+ ItemState state = ItemState.InContainer;
+ UpdateContainerItems(woid, state);
+ }
+
+ Log($"HandleChestOpened: Added {itemCount} items, containerItems.Count={containerItems.Count}, state={looterState}");
+
+ EnableBaseTimer();
+ UpdateStatus($"Chest opened, scanning {itemCount} items...");
+ }
+ else
+ {
+ // UB's else branch
+ looterState = LooterState.Open;
+ DoneLooting(false, false);
+ }
+ }
+ catch (Exception ex)
+ {
+ LogError($"Error handling chest opened: {ex.Message}");
+ }
+ }
+
+ private void HandleChestLockState(NetworkMessageEventArgs e)
+ {
+ try
+ {
+ int lockedObjID = e.Message.Value("object");
+ int lockedObjIDEffect = e.Message.Value("effect");
+
+ if (ubOwnedContainer && lockedObjID == targetContainerID)
+ {
+ if (lockedObjIDEffect == 148) // Locked
+ {
+ if (looterState == LooterState.Closing)
+ {
+ looterState = LooterState.Closed;
+ }
+ else
+ {
+ looterState = LooterState.Locked;
+ }
+ }
+ else if (lockedObjIDEffect == 147) // Unlocked
+ {
+ looterState = LooterState.Unlocked;
+ UpdateStatus("Chest unlocked");
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ LogError($"Error handling chest lock state: {ex.Message}");
+ }
+ }
+
+ #endregion
+
+ #region Timer Handler
+
+ private void BaseTimer_Timeout(Timer Source)
+ {
+ try
+ {
+ if (lastOpenContainer != core.Actions.OpenedContainer)
+ {
+ lastOpenContainer = core.Actions.OpenedContainer;
+ }
+
+ if (lastLooterState != looterState)
+ {
+ first = true;
+ lastLooterState = looterState;
+ }
+
+ switch (looterState)
+ {
+ case LooterState.Done:
+ // Nothing to do - timer will be stopped when appropriate
+ break;
+ case LooterState.Closed:
+ HandleStateClosed();
+ break;
+ case LooterState.Locked:
+ HandleStateLocked();
+ break;
+ case LooterState.Unlocked:
+ HandleStateUnlocked();
+ break;
+ case LooterState.Unlocking:
+ HandleStateUnlocking();
+ break;
+ case LooterState.Open:
+ HandleStateOpen();
+ break;
+ case LooterState.Opening:
+ HandleStateOpening();
+ break;
+ case LooterState.Looting:
+ HandleStateLooting();
+ break;
+ case LooterState.Closing:
+ HandleStateClosing();
+ break;
+ }
+ }
+ catch (Exception ex)
+ {
+ LogError($"Error in BaseTimer_Timeout: {ex.Message}");
+ }
+ }
+
+ #endregion
+
+ #region State Handlers
+
+ private void HandleStateClosed()
+ {
+ if (ubOwnedContainer)
+ {
+ looterState = LooterState.Done;
+ DoneLooting();
+ }
+ else
+ {
+ looterState = LooterState.Done;
+ }
+ }
+
+ private void HandleStateLocked()
+ {
+ if (runType == RunType.UI || runType == RunType.CommandLine)
+ {
+ looterState = LooterState.Unlocking;
+ }
+ else
+ {
+ DoneLooting(false, false);
+ }
+ }
+
+ private void HandleStateUnlocked()
+ {
+ looterState = LooterState.Opening;
+ lastAttempt = DateTime.UtcNow;
+ }
+
+ private void HandleStateUnlocking()
+ {
+ if (first || DateTime.UtcNow - lastAttempt >= TimeSpan.FromMilliseconds(settings.DelaySpeed))
+ {
+ if (unlockAttempt >= settings.MaxUnlockAttempts)
+ {
+ DoneLooting(false, false);
+ return;
+ }
+
+ lastAttempt = DateTime.UtcNow;
+ core.Actions.SelectItem(targetKeyID);
+ core.Actions.ApplyItem(targetKeyID, targetContainerID);
+ unlockAttempt++;
+ first = false;
+ UpdateStatus($"Unlocking... (attempt {unlockAttempt}/{settings.MaxUnlockAttempts})");
+ }
+ }
+
+ private void HandleStateOpen()
+ {
+ looterState = LooterState.Looting;
+ UpdateStatus("Looting items...");
+ }
+
+ private void HandleStateOpening()
+ {
+ if (core.Actions.OpenedContainer == targetContainerID)
+ {
+ looterState = LooterState.Open;
+ return;
+ }
+
+ if (first || DateTime.UtcNow - lastAttempt >= TimeSpan.FromMilliseconds(settings.DelaySpeed))
+ {
+ if (openAttempt >= settings.MaxOpenAttempts)
+ {
+ Log("Reached max open attempts");
+ DoneLooting(false, false);
+ return;
+ }
+
+ lastAttempt = DateTime.UtcNow;
+ core.Actions.UseItem(targetContainerID, 0);
+ openAttempt++;
+ first = false;
+ UpdateStatus($"Opening... (attempt {openAttempt}/{settings.MaxOpenAttempts})");
+ }
+ }
+
+ private void HandleStateLooting()
+ {
+ var itemsInContainer = containerItems.Where(x => x.Value == ItemState.InContainer);
+ var needsToBeLootedItems = containerItems.Where(x => x.Value == ItemState.NeedsToBeLooted);
+ var lootedItems = containerItems.Where(x => x.Value == ItemState.Looted);
+ var blacklistedItems = containerItems.Where(x => x.Value == ItemState.Blacklisted);
+ var ignoredItems = containerItems.Where(x => x.Value == ItemState.Ignore);
+ var requestingInfoItems = containerItems.Where(x => x.Value == ItemState.RequestingInfo);
+
+ // Process items that need classification (InContainer first, then RequestingInfo)
+ if (itemsInContainer.Count() > 0)
+ {
+ GetLootDecision(itemsInContainer.First().Key);
+ }
+ else if (requestingInfoItems.Count() > 0)
+ {
+ // Re-check items that were waiting for ID data
+ GetLootDecision(requestingInfoItems.First().Key);
+ }
+
+ // Loot items
+ foreach (int item in needsToBeLootedItems.Select(w => w.Key).ToList())
+ {
+ if (!settings.TestMode)
+ {
+ core.Actions.MoveItem(item, core.CharacterFilter.Id);
+ if (needsToBeLootedItems.First().Key == item)
+ {
+ lootAttemptCount++;
+ }
+ }
+ else
+ {
+ containerItems[item] = ItemState.Looted;
+ }
+
+ if (lootAttemptCount >= settings.AttemptsBeforeBlacklisting)
+ {
+ int blackListedItem = needsToBeLootedItems.First().Key;
+ containerItems[blackListedItem] = ItemState.Blacklisted;
+ }
+ }
+
+ // Check if done looting (all items are either looted, blacklisted, or ignored)
+ if (containerItems.Count() > 0 &&
+ lootedItems.Count() + blacklistedItems.Count() + ignoredItems.Count() == containerItemsList.Count())
+ {
+ if (ubOwnedContainer)
+ {
+ lastAttempt = DateTime.UtcNow;
+ looterState = LooterState.Closing;
+ }
+ else
+ {
+ DoneLooting();
+ }
+ }
+
+ UpdateStatus($"Looting... ({lootedItems.Count()}/{containerItems.Count} items)");
+ }
+
+ private void HandleStateClosing()
+ {
+ // Check if container is closed (match UB exactly - only check OpenedContainer == 0)
+ if (core.Actions.OpenedContainer == 0)
+ {
+ looterState = LooterState.Closed;
+ return;
+ }
+
+ // Try to close the chest
+ if (first || DateTime.UtcNow - lastAttempt >= TimeSpan.FromMilliseconds(settings.DelaySpeed))
+ {
+ lastAttempt = DateTime.UtcNow;
+ core.Actions.UseItem(targetContainerID, 0);
+ first = false;
+ UpdateStatus("Closing chest...");
+ }
+ }
+
+ #endregion
+
+ #region Loot Decision Logic
+
+ ///
+ /// Determine if an item should be looted based on VTank loot profile
+ /// Ported directly from Utility Belt's Looter.cs GetLootDecision method
+ ///
+ private void GetLootDecision(int item)
+ {
+ try
+ {
+ if (!core.Actions.IsValidObject(item))
+ {
+ containerItems.Remove(item);
+ containerItemsList.Remove(item);
+ return;
+ }
+
+ var wo = core.WorldFilter[item];
+
+ // Check if item needs ID first (red rules in VTank profile)
+ bool needsId = false;
+ try
+ {
+ needsId = uTank2.PluginCore.PC.FLootPluginQueryNeedsID(item);
+ }
+ catch
+ {
+ containerItems[item] = ItemState.Ignore;
+ return;
+ }
+
+ if (!needsId)
+ {
+ // Item doesn't need ID, classify immediately
+ dynamic result = null;
+ try
+ {
+ result = uTank2.PluginCore.PC.FLootPluginClassifyImmediate(item);
+ }
+ catch
+ {
+ containerItems[item] = ItemState.Ignore;
+ return;
+ }
+
+ if (result.IsKeep)
+ {
+ containerItems[item] = ItemState.NeedsToBeLooted;
+ }
+
+ if (result.IsKeepUpTo)
+ {
+ // IsKeepUpTo - check inventory count for this rule
+ int itemCount = 0;
+ bool waitingForInvItems = false;
+
+ using (var inv = core.WorldFilter.GetInventory())
+ {
+ foreach (WorldObject invItem in inv)
+ {
+ waitingForInvItems = false;
+
+ if (uTank2.PluginCore.PC.FLootPluginQueryNeedsID(invItem.Id))
+ {
+ core.Actions.RequestId(invItem.Id);
+ waitingForInvItems = true;
+ break;
+ }
+ else
+ {
+ var invItemResult = uTank2.PluginCore.PC.FLootPluginClassifyImmediate(invItem.Id);
+ if (result.RuleName == invItemResult.RuleName)
+ {
+ if (invItem.Values(LongValueKey.StackMax, 0) > 0)
+ {
+ itemCount += invItem.Values(LongValueKey.StackCount, 1);
+ }
+ else
+ {
+ itemCount++;
+ }
+ }
+ }
+ }
+ }
+
+ if (!waitingForInvItems)
+ {
+ if (itemCount >= result.Data1)
+ {
+ containerItems[item] = ItemState.Ignore;
+ }
+ else
+ {
+ containerItems[item] = ItemState.NeedsToBeLooted;
+ }
+ }
+ }
+
+ if (result.IsSalvage)
+ {
+ // Treat salvage items as loot (salvage feature not implemented)
+ containerItems[item] = ItemState.NeedsToBeLooted;
+ }
+
+ if (result.IsNoLoot)
+ {
+ containerItems[item] = ItemState.Ignore;
+ }
+
+ // If none of the above matched, item stays in current state (InContainer)
+ // This means it doesn't match any rule - we ignore it
+ if (!result.IsKeep && !result.IsKeepUpTo && !result.IsSalvage && !result.IsNoLoot)
+ {
+ containerItems[item] = ItemState.Ignore;
+ }
+
+ if (settings.TestMode)
+ {
+ containerItems[item] = ItemState.Looted;
+ }
+ }
+ else
+ {
+ // Item needs ID - request it and mark as RequestingInfo
+ if (containerItems[item] != ItemState.RequestingInfo)
+ {
+ core.Actions.RequestId(item);
+ containerItems[item] = ItemState.RequestingInfo;
+ }
+ }
+ }
+ catch
+ {
+ containerItems[item] = ItemState.Ignore;
+ }
+ }
+
+ #endregion
+
+ #region Helper Methods
+
+ private bool IsValidContainer(int containerId)
+ {
+ try
+ {
+ WorldObject containerWO = core.WorldFilter[containerId];
+ if (containerWO.ObjectClass != ObjectClass.Container &&
+ containerWO.ObjectClass != ObjectClass.Corpse)
+ {
+ return false;
+ }
+
+ // Check if it has item slots (is a real container)
+ try
+ {
+ if (containerWO.Values(LongValueKey.ItemSlots) < 48)
+ return false;
+ }
+ catch
+ {
+ return false;
+ }
+
+ return true;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ private ContainerType GetContainerType(int container)
+ {
+ try
+ {
+ var wo = core.WorldFilter[container];
+ switch (wo.ObjectClass)
+ {
+ case ObjectClass.Container:
+ return ContainerType.Chest;
+ case ObjectClass.Corpse:
+ if (wo.Name == $"Corpse of {core.CharacterFilter.Name}")
+ return ContainerType.MyCorpse;
+ else
+ return ContainerType.MonsterCorpse;
+ }
+ return ContainerType.Unknown;
+ }
+ catch
+ {
+ return ContainerType.Unknown;
+ }
+ }
+
+ private bool IsContainerPlayer(int container)
+ {
+ try
+ {
+ if (container == 0)
+ return false;
+
+ // Main pack
+ if (core.WorldFilter[container].Id == core.CharacterFilter.Id)
+ return true;
+
+ // Side pack
+ if (core.WorldFilter[container].Container == core.CharacterFilter.Id)
+ return true;
+
+ return false;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ private void UpdateContainerItems(int item, ItemState state)
+ {
+ try
+ {
+ if (!containerItems.ContainsKey(item))
+ {
+ containerItems.Add(item, state);
+ }
+ if (!containerItemsList.Contains(item))
+ {
+ containerItemsList.Add(item);
+ }
+ }
+ catch (Exception ex)
+ {
+ LogError($"Error updating container items: {ex.Message}");
+ }
+ }
+
+ private void LootedItem(int item)
+ {
+ try
+ {
+ if (containerItems.ContainsKey(item))
+ {
+ if (containerItems[item] != ItemState.Looted)
+ {
+ lootAttemptCount = 0;
+ containerItems[item] = ItemState.Looted;
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ LogError($"Error marking item as looted: {ex.Message}");
+ }
+ }
+
+ private bool HasMoreKeys(string keyName)
+ {
+ try
+ {
+ // Match UB's exact implementation - EXACT name match
+ int count = 0;
+ using (var inv = core.WorldFilter.GetInventory())
+ {
+ foreach (var wo in inv)
+ {
+ if (wo.Name == keyName) // EXACT match like UB
+ {
+ if (wo.Values(LongValueKey.StackCount, 0) > 0)
+ {
+ count += wo.Values(LongValueKey.StackCount);
+ }
+ else
+ {
+ count++;
+ }
+ }
+ }
+ }
+ return count > 0;
+ }
+ catch (Exception ex)
+ {
+ LogError($"HasMoreKeys exception: {ex.Message}");
+ return false;
+ }
+ }
+
+ private string GetStatusString()
+ {
+ if (looterState == LooterState.Done)
+ return "Ready";
+
+ return $"{looterState} - {containerItems.Count} items";
+ }
+
+ private void UpdateStatus(string status)
+ {
+ StatusChanged?.Invoke(this, status);
+ }
+
+ private void Log(string message)
+ {
+ PluginCore.WriteToChat($"[ChestLooter] {message}");
+ }
+
+ private void LogError(string message)
+ {
+ PluginCore.WriteToChat($"[ChestLooter] ERROR: {message}");
+ }
+
+ #endregion
+
+ #region IDisposable
+
+ public void Dispose()
+ {
+ Dispose(true);
+ GC.SuppressFinalize(this);
+ }
+
+ protected virtual void Dispose(bool disposing)
+ {
+ if (!disposed)
+ {
+ if (disposing)
+ {
+ try
+ {
+ if (baseTimer != null)
+ {
+ baseTimer.Timeout -= BaseTimer_Timeout;
+ baseTimer.Stop();
+ baseTimer = null;
+ }
+ DisableDispatch();
+ }
+ catch (Exception ex)
+ {
+ LogError($"Error during disposal: {ex.Message}");
+ }
+ }
+
+ disposed = true;
+ }
+ }
+
+ ~ChestLooter()
+ {
+ Dispose(false);
+ }
+
+ #endregion
+ }
+}
diff --git a/MosswartMassacre/ChestLooterSettings.cs b/MosswartMassacre/ChestLooterSettings.cs
new file mode 100644
index 0000000..517925a
--- /dev/null
+++ b/MosswartMassacre/ChestLooterSettings.cs
@@ -0,0 +1,122 @@
+using System;
+
+namespace MosswartMassacre
+{
+ ///
+ /// Settings for the Chest Looter feature
+ /// These settings are persisted per-character via PluginSettings
+ ///
+ public class ChestLooterSettings
+ {
+ // Target configuration
+ public string ChestName { get; set; } = "";
+ public string KeyName { get; set; } = "";
+
+ // Feature toggles
+ public bool Enabled { get; set; } = false;
+ public bool EnableChests { get; set; } = true;
+ public bool AutoSalvageAfterLooting { get; set; } = false;
+ public bool JumpWhenLooting { get; set; } = false;
+ public bool BlockVtankMelee { get; set; } = false;
+ public bool TestMode { get; set; } = false;
+ public bool VerboseLogging { get; set; } = false;
+
+ // Timing and retry settings
+ public int DelaySpeed { get; set; } = 1000; // Delay for unlock/open/close in ms
+ public int OverallSpeed { get; set; } = 100; // Overall looter tick rate in ms
+ public int MaxUnlockAttempts { get; set; } = 10; // Max attempts to unlock chest
+ public int MaxOpenAttempts { get; set; } = 10; // Max attempts to open chest
+ public int AttemptsBeforeBlacklisting { get; set; } = 500; // Item loot attempts before giving up
+
+ // Jump looting settings
+ public int JumpHeight { get; set; } = 100; // Jump height (full bar is 1000)
+
+ // UI state
+ public bool ShowChestLooterTab { get; set; } = true;
+
+ ///
+ /// Constructor with default values
+ ///
+ public ChestLooterSettings()
+ {
+ // All defaults set via property initializers above
+ }
+
+ ///
+ /// Validate settings and apply constraints
+ ///
+ public void Validate()
+ {
+ // Ensure OverallSpeed isn't too fast (can cause issues)
+ if (OverallSpeed < 100)
+ OverallSpeed = 100;
+
+ // Ensure delays are reasonable
+ if (DelaySpeed < 500)
+ DelaySpeed = 500;
+
+ // Ensure attempt limits are positive
+ if (MaxUnlockAttempts < 1)
+ MaxUnlockAttempts = 1;
+ if (MaxOpenAttempts < 1)
+ MaxOpenAttempts = 1;
+ if (AttemptsBeforeBlacklisting < 1)
+ AttemptsBeforeBlacklisting = 1;
+
+ // Clamp jump height to reasonable range
+ if (JumpHeight < 0)
+ JumpHeight = 0;
+ if (JumpHeight > 1000)
+ JumpHeight = 1000;
+ }
+
+ ///
+ /// Reset all settings to default values
+ ///
+ public void Reset()
+ {
+ ChestName = "";
+ KeyName = "";
+ Enabled = false;
+ EnableChests = true;
+ AutoSalvageAfterLooting = false;
+ JumpWhenLooting = false;
+ BlockVtankMelee = false;
+ TestMode = false;
+ VerboseLogging = false;
+ DelaySpeed = 1000;
+ OverallSpeed = 100;
+ MaxUnlockAttempts = 10;
+ MaxOpenAttempts = 10;
+ AttemptsBeforeBlacklisting = 500;
+ JumpHeight = 100;
+ ShowChestLooterTab = true;
+ }
+
+ ///
+ /// Create a copy of these settings
+ ///
+ public ChestLooterSettings Clone()
+ {
+ return new ChestLooterSettings
+ {
+ ChestName = this.ChestName,
+ KeyName = this.KeyName,
+ Enabled = this.Enabled,
+ EnableChests = this.EnableChests,
+ AutoSalvageAfterLooting = this.AutoSalvageAfterLooting,
+ JumpWhenLooting = this.JumpWhenLooting,
+ BlockVtankMelee = this.BlockVtankMelee,
+ TestMode = this.TestMode,
+ VerboseLogging = this.VerboseLogging,
+ DelaySpeed = this.DelaySpeed,
+ OverallSpeed = this.OverallSpeed,
+ MaxUnlockAttempts = this.MaxUnlockAttempts,
+ MaxOpenAttempts = this.MaxOpenAttempts,
+ AttemptsBeforeBlacklisting = this.AttemptsBeforeBlacklisting,
+ JumpHeight = this.JumpHeight,
+ ShowChestLooterTab = this.ShowChestLooterTab
+ };
+ }
+ }
+}
diff --git a/MosswartMassacre/ClientTelemetry.cs b/MosswartMassacre/ClientTelemetry.cs
new file mode 100644
index 0000000..78a9791
--- /dev/null
+++ b/MosswartMassacre/ClientTelemetry.cs
@@ -0,0 +1,35 @@
+using System.Diagnostics;
+using System.Threading;
+using System;
+
+public class ClientTelemetry
+{
+ private readonly Process _proc;
+
+ public ClientTelemetry()
+ {
+ _proc = Process.GetCurrentProcess();
+ }
+
+ /// Working-set memory in bytes.
+ public long MemoryBytes => _proc.WorkingSet64;
+
+ /// Total open handles.
+ public int HandleCount => _proc.HandleCount;
+
+ /// CPU utilisation (%) averaged over .
+ public float GetCpuUsage(int sampleMs = 500)
+ {
+ // you can keep your PerformanceCounter variant, but here’s a simpler PID-based way:
+ var startCpu = _proc.TotalProcessorTime;
+ var start = DateTime.UtcNow;
+ Thread.Sleep(sampleMs);
+ var endCpu = _proc.TotalProcessorTime;
+ var end = DateTime.UtcNow;
+
+ // CPU‐time used across all cores:
+ var cpuMs = (endCpu - startCpu).TotalMilliseconds;
+ var elapsedMs = (end - start).TotalMilliseconds * Environment.ProcessorCount;
+ return (float)(cpuMs / elapsedMs * 100.0);
+ }
+}
diff --git a/MosswartMassacre/CommandRouter.cs b/MosswartMassacre/CommandRouter.cs
new file mode 100644
index 0000000..edba553
--- /dev/null
+++ b/MosswartMassacre/CommandRouter.cs
@@ -0,0 +1,67 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace MosswartMassacre
+{
+ ///
+ /// Dictionary-based /mm command dispatcher. Commands are registered with descriptions
+ /// and routed by name lookup instead of a giant switch statement.
+ ///
+ internal class CommandRouter
+ {
+ private readonly Dictionary handler, string description)> _commands
+ = new Dictionary, string)>(StringComparer.OrdinalIgnoreCase);
+
+ ///
+ /// Register a command with its handler and help description.
+ ///
+ internal void Register(string name, Action handler, string description)
+ {
+ _commands[name] = (handler, description);
+ }
+
+ ///
+ /// Dispatch a raw /mm command string. Returns false if the command was not found.
+ ///
+ internal bool Dispatch(string rawText)
+ {
+ string[] args = rawText.Substring(3).Trim().Split(' ');
+
+ if (args.Length == 0 || string.IsNullOrEmpty(args[0]))
+ {
+ PluginCore.WriteToChat("Usage: /mm . Try /mm help");
+ return true;
+ }
+
+ string subCommand = args[0].ToLower();
+
+ if (subCommand == "help")
+ {
+ PrintHelp();
+ return true;
+ }
+
+ if (_commands.TryGetValue(subCommand, out var entry))
+ {
+ entry.handler(args);
+ return true;
+ }
+
+ PluginCore.WriteToChat($"Unknown /mm command: {subCommand}. Try /mm help");
+ return false;
+ }
+
+ private void PrintHelp()
+ {
+ PluginCore.WriteToChat("Mosswart Massacre Commands:");
+ foreach (var kvp in _commands)
+ {
+ if (!string.IsNullOrEmpty(kvp.Value.description))
+ {
+ PluginCore.WriteToChat($"/mm {kvp.Key,-18} - {kvp.Value.description}");
+ }
+ }
+ }
+ }
+}
diff --git a/MosswartMassacre/Constants.cs b/MosswartMassacre/Constants.cs
new file mode 100644
index 0000000..c944d43
--- /dev/null
+++ b/MosswartMassacre/Constants.cs
@@ -0,0 +1,36 @@
+namespace MosswartMassacre
+{
+ ///
+ /// Centralized constants for timer intervals, message type IDs, and property keys.
+ ///
+ internal static class Constants
+ {
+ // Timer intervals (milliseconds)
+ internal const int StatsUpdateIntervalMs = 1000;
+ internal const int VitalsUpdateIntervalMs = 5000;
+ internal const int CommandProcessIntervalMs = 10;
+ internal const int QuestStreamingIntervalMs = 30000;
+ internal const int CharacterStatsIntervalMs = 600000; // 10 minutes
+ internal const int LoginDelayMs = 5000;
+
+ // Network message types
+ internal const int GameEventMessageType = 0xF7B0;
+ internal const int PrivateUpdatePropertyInt64 = 0x02CF;
+
+ // Game event IDs (sub-events within 0xF7B0)
+ internal const int AllegianceInfoEvent = 0x0020;
+ internal const int LoginCharacterEvent = 0x0013;
+ internal const int TitlesListEvent = 0x0029;
+ internal const int SetTitleEvent = 0x002b;
+
+ // Int64 property keys
+ internal const int AvailableLuminanceKey = 6;
+ internal const int MaximumLuminanceKey = 7;
+
+ ///
+ /// Plugin version derived from assembly version (CalVer: YYYY.M.D.HHmm)
+ ///
+ public static string PluginVersion =>
+ typeof(Constants).Assembly.GetName().Version.ToString();
+ }
+}
diff --git a/MosswartMassacre/DecalHarmonyClean.cs b/MosswartMassacre/DecalHarmonyClean.cs
new file mode 100644
index 0000000..0bcdf23
--- /dev/null
+++ b/MosswartMassacre/DecalHarmonyClean.cs
@@ -0,0 +1,559 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Reflection;
+using System.Threading.Tasks;
+using Decal.Adapter;
+using Decal.Adapter.Wrappers;
+using Harmony; // UtilityBelt's Harmony 1.2.0.1 uses old Harmony namespace
+
+namespace MosswartMassacre
+{
+ ///
+ /// Clean Harmony implementation using UtilityBelt's exact pattern
+ /// Uses same namespace convention, same API, same Harmony package (Lib.Harmony 2.2.2)
+ ///
+ public static class DecalHarmonyClean
+ {
+ // Use UtilityBelt's namespace pattern
+ private static readonly string harmonyNamespace = "com.mosswartmassacre.decal";
+ private static HarmonyInstance harmonyDecal;
+ private static bool patchesApplied = false;
+ internal static int messagesIntercepted = 0;
+
+ // Debug logging for troubleshooting
+ private static readonly Queue debugLog = new Queue();
+ private const int MAX_DEBUG_ENTRIES = 50;
+
+ ///
+ /// Initialize Harmony patches using UtilityBelt's exact pattern
+ ///
+ public static bool Initialize()
+ {
+ try
+ {
+ // Use UtilityBelt's exact pattern: lazy initialization check + new Harmony()
+ if (harmonyDecal == null)
+ harmonyDecal = HarmonyInstance.Create(harmonyNamespace + ".patches");
+
+ // Apply patches using UtilityBelt's approach
+ ApplyDecalPatches();
+
+ return patchesApplied;
+ }
+ catch (Exception ex)
+ {
+ // Only log critical initialization failures
+ PluginCore.WriteToChat($"[DECAL] Critical initialization error: {ex.Message}");
+ return false;
+ }
+ }
+
+ ///
+ /// Apply patches to DECAL's AddChatText methods using UtilityBelt approach
+ ///
+ private static void ApplyDecalPatches()
+ {
+ try
+ {
+ // PATHWAY 1: Target HooksWrapper.AddChatText
+ PatchHooksWrapper();
+
+ // PATHWAY 2: Target Host.Actions.AddChatText (what our plugin uses)
+ PatchHostActions();
+ }
+ catch (Exception ex)
+ {
+ AddDebugLog($"ApplyDecalPatches failed: {ex.Message}");
+ }
+ }
+
+ ///
+ /// Patch HooksWrapper.AddChatText methods
+ ///
+ private static void PatchHooksWrapper()
+ {
+ try
+ {
+ Type hooksWrapperType = typeof(HooksWrapper);
+
+ var allAddChatTextMethods = hooksWrapperType.GetMethods(BindingFlags.Public | BindingFlags.Instance)
+ .Where(m => m.Name == "AddChatText" || m.Name == "AddChatTextRaw").ToArray();
+
+ foreach (var method in allAddChatTextMethods)
+ {
+ var parameters = method.GetParameters();
+
+ string prefixMethodName = parameters.Length == 2 ? "AddChatTextPrefixHooks" :
+ parameters.Length == 3 ? "AddChatTextPrefixHooks3" :
+ "AddChatTextPrefixHooksGeneric";
+
+ try
+ {
+ ApplySinglePatch(method, prefixMethodName);
+ }
+ catch (Exception ex)
+ {
+ AddDebugLog($"PatchHooksWrapper single patch failed ({prefixMethodName}): {ex.Message}");
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ AddDebugLog($"PatchHooksWrapper failed: {ex.Message}");
+ }
+ }
+
+ ///
+ /// Patch Host.Actions.AddChatText methods (what our plugin uses)
+ ///
+ private static void PatchHostActions()
+ {
+ try
+ {
+ if (PluginCore.MyHost?.Actions == null)
+ {
+ return;
+ }
+
+ Type actionsType = PluginCore.MyHost.Actions.GetType();
+
+ // Check if Host.Actions is already a HooksWrapper (to avoid double patching)
+ if (actionsType == typeof(HooksWrapper))
+ {
+ // PATHWAY 3: Try to patch at PluginHost level
+ PatchPluginHost();
+ return;
+ }
+
+ var hostAddChatTextMethods = actionsType.GetMethods(BindingFlags.Public | BindingFlags.Instance)
+ .Where(m => m.Name == "AddChatText" || m.Name == "AddChatTextRaw").ToArray();
+
+ foreach (var method in hostAddChatTextMethods)
+ {
+ var parameters = method.GetParameters();
+
+ string prefixMethodName = parameters.Length == 3 ? "AddChatTextPrefixHost" :
+ parameters.Length == 4 ? "AddChatTextPrefixHost4" :
+ "AddChatTextPrefixHostGeneric";
+
+ try
+ {
+ ApplySinglePatch(method, prefixMethodName);
+ }
+ catch (Exception ex)
+ {
+ AddDebugLog($"PatchHostActions single patch failed ({prefixMethodName}): {ex.Message}");
+ }
+ }
+
+ // PATHWAY 3: Try to patch at PluginHost level
+ PatchPluginHost();
+ }
+ catch (Exception ex)
+ {
+ AddDebugLog($"PatchHostActions failed: {ex.Message}");
+ }
+ }
+
+ ///
+ /// Try a different approach - patch the actual chat system or find global instances
+ ///
+ private static void PatchPluginHost()
+ {
+ try
+ {
+ var coreActions = CoreManager.Current?.Actions;
+ if (coreActions != null && coreActions != PluginCore.MyHost?.Actions)
+ {
+ var coreActionsMethods = coreActions.GetType().GetMethods(BindingFlags.Public | BindingFlags.Instance)
+ .Where(m => m.Name == "AddChatText" || m.Name == "AddChatTextRaw" || m.Name == "AddStatusText").ToArray();
+
+ foreach (var method in coreActionsMethods)
+ {
+ var parameters = method.GetParameters();
+
+ try
+ {
+ string prefixMethodName = "AddChatTextPrefixCore" + parameters.Length;
+ ApplySinglePatch(method, prefixMethodName);
+ }
+ catch (Exception ex)
+ {
+ AddDebugLog($"PatchPluginHost single patch failed: {ex.Message}");
+ }
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ AddDebugLog($"PatchPluginHost failed: {ex.Message}");
+ }
+ }
+
+ ///
+ /// Apply a single patch using UtilityBelt's method
+ ///
+ private static void ApplySinglePatch(MethodInfo targetMethod, string prefixMethodName)
+ {
+ try
+ {
+ // Get our prefix method
+ var prefixMethod = typeof(DecalPatchMethods).GetMethod(prefixMethodName,
+ BindingFlags.Static | BindingFlags.Public);
+
+ if (prefixMethod != null)
+ {
+ // Use UtilityBelt's exact approach
+ harmonyDecal.Patch(targetMethod, new HarmonyMethod(prefixMethod));
+ patchesApplied = true;
+ }
+ }
+ catch (Exception ex)
+ {
+ AddDebugLog($"ApplySinglePatch failed ({prefixMethodName}): {ex.Message}");
+ }
+ }
+
+ ///
+ /// List available methods for debugging
+ ///
+ private static void ListAvailableMethods(Type type)
+ {
+ var methods = type.GetMethods(BindingFlags.Public | BindingFlags.Instance)
+ .Where(m => m.Name == "AddChatText").ToArray();
+
+ foreach (var method in methods)
+ {
+ var parameters = method.GetParameters();
+ string paramInfo = string.Join(", ", parameters.Select(p => p.ParameterType.Name));
+ }
+ }
+
+ ///
+ /// Clean up patches using UtilityBelt's pattern
+ ///
+ public static void Cleanup()
+ {
+ try
+ {
+ if (harmonyDecal != null && patchesApplied)
+ {
+ // Use UtilityBelt's cleanup pattern
+ harmonyDecal.UnpatchAll(harmonyNamespace + ".patches");
+ }
+ patchesApplied = false;
+ }
+ catch (Exception ex)
+ {
+ AddDebugLog($"Cleanup failed: {ex.Message}");
+ }
+ }
+
+ ///
+ /// Status checks
+ ///
+ public static bool IsActive() => patchesApplied && harmonyDecal != null;
+ public static int GetMessagesIntercepted() => messagesIntercepted;
+
+ ///
+ /// Add debug log entry
+ ///
+ public static void AddDebugLog(string message)
+ {
+ lock (debugLog)
+ {
+ debugLog.Enqueue($"{DateTime.Now:HH:mm:ss.fff} {message}");
+ while (debugLog.Count > MAX_DEBUG_ENTRIES)
+ {
+ debugLog.Dequeue();
+ }
+ }
+ }
+
+ ///
+ /// Get debug log entries
+ ///
+ public static string[] GetDebugLog()
+ {
+ lock (debugLog)
+ {
+ return debugLog.ToArray();
+ }
+ }
+ }
+
+ ///
+ /// Patch methods for DECAL interception using UtilityBelt's approach
+ ///
+ public static class DecalPatchMethods
+ {
+ ///
+ /// Prefix method for HooksWrapper.AddChatText(string, int) - intercepts plugin messages via HooksWrapper
+ ///
+ public static bool AddChatTextPrefixHooks(string text, int color)
+ {
+ try
+ {
+ // Always increment to verify patch is working
+ DecalHarmonyClean.messagesIntercepted++;
+
+ if (PluginCore.AggressiveChatStreamingEnabled)
+ {
+ }
+
+ // Process ALL messages (including our own) for streaming
+ if (!string.IsNullOrEmpty(text))
+ {
+ // Process the intercepted message
+ ProcessInterceptedMessage(text, color, "HooksWrapper-2param");
+
+ // Silent operation - debug output removed
+ }
+
+ // Always return true to let the original AddChatText continue
+ return true;
+ }
+ catch
+ {
+ // Never let our interception break other plugins
+ return true;
+ }
+ }
+
+ ///
+ /// Prefix method for HooksWrapper.AddChatText(string, int, int) - 3-parameter version
+ ///
+ public static bool AddChatTextPrefixHooks3(string text, int color, int target)
+ {
+ try
+ {
+ DecalHarmonyClean.messagesIntercepted++;
+
+ if (!string.IsNullOrEmpty(text))
+ {
+ ProcessInterceptedMessage(text, color, $"HooksWrapper-3param(target={target})");
+
+ // Silent operation - debug output removed
+ }
+
+ return true;
+ }
+ catch
+ {
+ return true;
+ }
+ }
+
+ ///
+ /// Generic prefix method for any other HooksWrapper AddChatText overloads
+ ///
+ public static bool AddChatTextPrefixHooksGeneric(string text)
+ {
+ try
+ {
+ DecalHarmonyClean.messagesIntercepted++;
+
+ if (!string.IsNullOrEmpty(text))
+ {
+ ProcessInterceptedMessage(text, 0, "HooksWrapper-generic");
+
+ // Silent operation - debug output removed
+ }
+
+ return true;
+ }
+ catch
+ {
+ return true;
+ }
+ }
+
+ ///
+ /// Process intercepted plugin messages
+ ///
+ private static void ProcessInterceptedMessage(string text, int color, string source)
+ {
+ try
+ {
+ // Identify source plugin
+ string sourcePlugin = IdentifySourcePlugin(text);
+
+ // Add timestamp
+ var timestamp = DateTime.Now.ToString("HH:mm:ss");
+ var fullMessage = $"{timestamp} [{sourcePlugin}] {text}";
+
+ // Debug logging
+
+ // Stream to WebSocket if both debug streaming AND WebSocket are enabled
+ if (PluginCore.AggressiveChatStreamingEnabled && PluginCore.WebSocketEnabled)
+ {
+ Task.Run(() => WebSocket.SendChatTextAsync(color, text));
+ }
+ }
+ catch (Exception ex)
+ {
+ DecalHarmonyClean.AddDebugLog($"ProcessInterceptedMessage failed: {ex.Message}");
+ }
+ }
+
+ ///
+ /// Identify which plugin sent the message using UtilityBelt's patterns
+ ///
+ private static string IdentifySourcePlugin(string text)
+ {
+ // Known plugin prefixes
+ if (text.StartsWith("[VTank]")) return "VTank";
+ if (text.StartsWith("[UB]") || text.Contains("UtilityBelt")) return "UtilityBelt";
+ if (text.StartsWith("[VGI]")) return "VGI";
+ if (text.StartsWith("[VI]")) return "VirindiIntegrator";
+ if (text.StartsWith("[GoArrow]")) return "GoArrow";
+ if (text.StartsWith("[Meta]")) return "Meta";
+ if (text.StartsWith("[VTClassic]")) return "VTClassic";
+
+ // Pattern-based detection for messages without prefixes
+ if (text.Contains("Macro started") || text.Contains("Macro stopped")) return "VTank";
+ if (text.Contains("Quest data updated")) return "UtilityBelt";
+ if (text.Contains("Database read complete")) return "VGI";
+ if (text.Contains("Disconnected, retry")) return "VI";
+
+ return "Unknown";
+ }
+
+ // ===== HOST.ACTIONS PREFIX METHODS =====
+
+ ///
+ /// Prefix method for Host.Actions.AddChatText(string, int, int) - 3-parameter version
+ ///
+ public static bool AddChatTextPrefixHost(string text, int color, int target)
+ {
+ try
+ {
+ DecalHarmonyClean.messagesIntercepted++;
+
+ if (PluginCore.AggressiveChatStreamingEnabled)
+ {
+ }
+
+ if (!string.IsNullOrEmpty(text) && !text.Contains("[Mosswart Massacre]"))
+ {
+ ProcessInterceptedMessage(text, color, $"Host.Actions-3param(target={target})");
+
+ // Silent operation - debug output removed
+ }
+
+ return true;
+ }
+ catch
+ {
+ return true;
+ }
+ }
+
+ ///
+ /// Prefix method for Host.Actions.AddChatText(string, int, int, int) - 4-parameter version
+ ///
+ public static bool AddChatTextPrefixHost4(string text, int color, int target, int window)
+ {
+ try
+ {
+ DecalHarmonyClean.messagesIntercepted++;
+
+ if (!string.IsNullOrEmpty(text) && !text.Contains("[Mosswart Massacre]"))
+ {
+ ProcessInterceptedMessage(text, color, $"Host.Actions-4param(target={target},window={window})");
+
+ // Silent operation - debug output removed
+ }
+
+ return true;
+ }
+ catch
+ {
+ return true;
+ }
+ }
+
+ ///
+ /// Generic prefix method for any other Host.Actions.AddChatText overloads
+ ///
+ public static bool AddChatTextPrefixHostGeneric(string text)
+ {
+ try
+ {
+ DecalHarmonyClean.messagesIntercepted++;
+
+ if (!string.IsNullOrEmpty(text) && !text.Contains("[Mosswart Massacre]"))
+ {
+ ProcessInterceptedMessage(text, 0, "Host.Actions-generic");
+
+ // Silent operation - debug output removed
+ }
+
+ return true;
+ }
+ catch
+ {
+ return true;
+ }
+ }
+
+ // ===== COREMANAGER.ACTIONS PREFIX METHODS =====
+
+ ///
+ /// Prefix method for CoreManager.Actions methods - trying different instances
+ ///
+ public static bool AddChatTextPrefixCore2(string text, int color)
+ {
+ try
+ {
+ DecalHarmonyClean.messagesIntercepted++;
+
+ if (PluginCore.AggressiveChatStreamingEnabled)
+ {
+ }
+
+ if (!string.IsNullOrEmpty(text) && !text.Contains("[Mosswart Massacre]"))
+ {
+ ProcessInterceptedMessage(text, color, "CoreActions-2param");
+
+ // Silent operation - debug output removed
+ }
+
+ return true;
+ }
+ catch
+ {
+ return true;
+ }
+ }
+
+ ///
+ /// Prefix method for CoreManager.Actions 3-param methods
+ ///
+ public static bool AddChatTextPrefixCore3(string text, int color, int target)
+ {
+ try
+ {
+ DecalHarmonyClean.messagesIntercepted++;
+
+ if (PluginCore.AggressiveChatStreamingEnabled)
+ {
+ }
+
+ if (!string.IsNullOrEmpty(text) && !text.Contains("[Mosswart Massacre]"))
+ {
+ ProcessInterceptedMessage(text, color, $"CoreActions-3param(target={target})");
+
+ // Silent operation - debug output removed
+ }
+
+ return true;
+ }
+ catch
+ {
+ return true;
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/MosswartMassacre/DelayedCommandManager.cs b/MosswartMassacre/DelayedCommandManager.cs
index 4976e4b..bf7a400 100644
--- a/MosswartMassacre/DelayedCommandManager.cs
+++ b/MosswartMassacre/DelayedCommandManager.cs
@@ -28,8 +28,8 @@ namespace MosswartMassacre
{
while (delayedCommands.Count > 0 && delayedCommands[0].RunAt <= DateTime.UtcNow)
{
- PluginCore.WriteToChat($"[Debug] Executing delayed: {delayedCommands[0].Command}");
- CoreManager.Current.Actions.InvokeChatParser(delayedCommands[0].Command);
+ // Use Decal_DispatchOnChatCommand to ensure other plugins can intercept
+ PluginCore.DispatchChatToBoxWithPluginIntercept(delayedCommands[0].Command);
delayedCommands.RemoveAt(0);
}
diff --git a/MosswartMassacre/FlagTrackerData.cs b/MosswartMassacre/FlagTrackerData.cs
new file mode 100644
index 0000000..9ac261d
--- /dev/null
+++ b/MosswartMassacre/FlagTrackerData.cs
@@ -0,0 +1,1932 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Reflection;
+using Decal.Adapter;
+using Decal.Adapter.Wrappers;
+using Decal.Filters;
+using Mag.Shared.Constants;
+
+namespace MosswartMassacre
+{
+ ///
+ /// Data management class for Flag Tracker
+ /// Ported from UBS Lua flagtracker data structures
+ ///
+ public class FlagTrackerData : IDisposable
+ {
+ public FlagTrackerData()
+ {
+ InitializeDataStructures();
+ }
+ #region Augmentation Data Structures
+ public class AugmentationInfo
+ {
+ public string Name { get; set; }
+ public int? IntId { get; set; }
+ public int Repeatable { get; set; }
+ public string Trainer { get; set; }
+ public string Location { get; set; }
+ public int CurrentValue { get; set; }
+ public bool IsMaxed => CurrentValue >= Repeatable;
+ }
+
+ public Dictionary> AugmentationCategories { get; private set; }
+
+ #endregion
+
+ #region Luminance Aura Data Structures
+ public class LuminanceAuraInfo
+ {
+ public string Name { get; set; }
+ public int IntId { get; set; }
+ public int Cap { get; set; }
+ public string QuestFlag { get; set; } // For Seer auras only
+ public int CurrentValue { get; set; }
+ }
+
+ public Dictionary> LuminanceAuraCategories { get; private set; }
+ #endregion
+
+ #region Recall Spell Data Structures
+ public class RecallSpellInfo
+ {
+ public string Name { get; set; }
+ public int SpellId { get; set; }
+ public bool IsKnown { get; set; }
+ public int IconId { get; set; }
+ public string Category { get; set; }
+ }
+
+ public List RecallSpells { get; private set; }
+ #endregion
+
+ #region Society Quest Data Structures
+ public class SocietyQuestInfo
+ {
+ public string Name { get; set; }
+ public string StartTag { get; set; }
+ public string EndTag { get; set; }
+ public QuestType Type { get; set; }
+ public object[] ExtraData { get; set; } // For type-specific data
+ }
+
+ public enum QuestType
+ {
+ Other = 0,
+ KillTask = 1,
+ CollectItem = 2,
+ QuestTag = 3,
+ MultiQuestTag = 4
+ }
+
+ public Dictionary> SocietyQuests { get; private set; }
+ #endregion
+
+ #region Character Flag Data Structures
+ public class CharacterFlagInfo
+ {
+ public string Name { get; set; }
+ public string QuestFlag { get; set; }
+ public QuestInfoType InfoType { get; set; }
+ }
+
+ public enum QuestInfoType
+ {
+ SolveCount = 1,
+ ReadyCheck = 2,
+ StampCheck = 3
+ }
+
+ public Dictionary> CharacterFlags { get; private set; }
+ #endregion
+
+ #region Cantrip Data Structures
+ public class CantripInfo
+ {
+ public string Name { get; set; }
+ public string Value { get; set; } // "Minor", "Major", "Epic", etc.
+ public System.Drawing.Color Color { get; set; }
+ public int? IconId { get; set; } // Skill icon from character skills
+ public int? SpellIconId { get; set; } // Spell icon (for attributes)
+ public int? BackgroundIconId { get; set; } // Background icon (for attributes)
+ public int ComputedIconId { get; set; } // Final resolved icon for display
+ }
+
+ public Dictionary> Cantrips { get; private set; }
+
+ // Skill name mappings for cantrips that have different names than their skills
+ private readonly Dictionary SkillCantripReplacements = new Dictionary
+ {
+ [15] = "MagicResistance", // MagicDefense
+ [6] = "Invulnerability", // MeleeDefense
+ [7] = "Impgrenability", // MissileDefense
+ [47] = "MissileWeapon", // MissileWeapons
+ [44] = "HeavyWeapon", // HeavyWeapons
+ [45] = "LightWeapon", // LightWeapons
+ [46] = "FinesseWeapon" // FinesseWeapons
+ };
+ #endregion
+
+ #region Weapon Data Structures (Legacy - to be removed)
+ // Old weapon structure - keeping for compatibility but unused
+ #endregion
+
+ #region Cached Data
+ private DateTime lastUpdateTime = DateTime.MinValue;
+ private Dictionary cachedValues = new Dictionary();
+ #endregion
+
+ #region Initialization
+ private void InitializeDataStructures()
+ {
+ InitializeAugmentationData();
+ InitializeLuminanceAuraData();
+ InitializeRecallSpellData();
+ InitializeSocietyQuestData();
+ InitializeCharacterFlagData();
+ InitializeCantripData();
+ InitializeNewWeaponData();
+ }
+
+ private void InitializeAugmentationData()
+ {
+ AugmentationCategories = new Dictionary>
+ {
+ ["Death Augs"] = new List
+ {
+ new AugmentationInfo { Name = "Keep Items", IntId = 231, Repeatable = 3, Trainer = "Rohula bint Ludun", Location = "Ayan Baqur" }, // AugmentationLessDeathItemLoss
+ new AugmentationInfo { Name = "Keep Spells", IntId = 232, Repeatable = 1, Trainer = "Erik Festus", Location = "Ayan Baqur" } // AugmentationSpellsRemainPastDeath
+ },
+ ["Skill Augs"] = new List
+ {
+ new AugmentationInfo { Name = "+5 All Skills", IntId = 326, Repeatable = 1, Trainer = "Arianna the Adept", Location = "Bandit Castle" }, // AugmentationJackOfAllTrades
+ new AugmentationInfo { Name = "+10 Melee Skills", IntId = 300, Repeatable = 1, Trainer = "Carlito Gallo", Location = "Silyun" }, // AugmentationSkilledMelee
+ new AugmentationInfo { Name = "+10 Magic Skills", IntId = 302, Repeatable = 1, Trainer = "Rahina bint Zalanis", Location = "Zaikhal" }, // AugmentationSkilledMagic
+ new AugmentationInfo { Name = "+10 Missile Skills", IntId = 301, Repeatable = 1, Trainer = "Kilaf", Location = "Zaikhal" } // AugmentationSkilledMissile
+ },
+ ["Rating Augs"] = new List
+ {
+ new AugmentationInfo { Name = "25% Crit Protection", IntId = 233, Repeatable = 1, Trainer = "Piersanti Linante", Location = "Sanamar" }, // AugmentationCriticalDefense
+ new AugmentationInfo { Name = "1% Critical Chance", IntId = 298, Repeatable = 1, Trainer = "Anfram Mellow", Location = "Ayan Baqur" }, // AugmentationCriticalExpertise
+ new AugmentationInfo { Name = "3% Critical Damage", IntId = 299, Repeatable = 1, Trainer = "Alishia bint Aldan", Location = "Ayan Baqur" }, // AugmentationCriticalPower
+ new AugmentationInfo { Name = "3% Damage Rating", IntId = 309, Repeatable = 1, Trainer = "Neela Nashua", Location = "Bandit Castle" }, // AugmentationDamageBonus
+ new AugmentationInfo { Name = "3% Damage Reduction", IntId = 310, Repeatable = 1, Trainer = "Emily Yarow", Location = "Cragstone" } // AugmentationDamageReduction
+ },
+ ["Burden / Pack Augs"] = new List
+ {
+ new AugmentationInfo { Name = "Extra Carrying Capacity", IntId = 230, Repeatable = 5, Trainer = "Husoon", Location = "Zaikhal" }, // AugmentationIncreasedCarryingCapacity
+ new AugmentationInfo { Name = "Extra Pack Slot", IntId = 229, Repeatable = 1, Trainer = "Dumida bint Ruminre", Location = "Zaikhal" }, // AugmentationExtraPackSlot
+ new AugmentationInfo { Name = "Infused War Magic", IntId = 297, Repeatable = 1, Trainer = "Raphel Detante", Location = "Silyun" }, // AugmentationInfusedWarMagic
+ new AugmentationInfo { Name = "Infused Void Magic", IntId = 328, Repeatable = 1, Trainer = "Morathe", Location = "Candeth Keep" }, // AugmentationInfusedVoidMagic
+ new AugmentationInfo { Name = "Infused Creature Magic", IntId = 294, Repeatable = 1, Trainer = "Gustuv Lansdown", Location = "Cragstone" }, // AugmentationInfusedCreatureMagic
+ new AugmentationInfo { Name = "Infused Life Magic", IntId = 296, Repeatable = 1, Trainer = "Akemi Fei", Location = "Hebian-To" }, // AugmentationInfusedLifeMagic
+ new AugmentationInfo { Name = "Infused Item Magic", IntId = 295, Repeatable = 1, Trainer = "Gan Fo", Location = "Hebian-To" } // AugmentationInfusedItemMagic
+ },
+ ["Misc Augs"] = new List
+ {
+ new AugmentationInfo { Name = "10% Health Increase", IntId = null, Repeatable = 1, Trainer = "Donatello Linante", Location = "Silyun" }, // No specific property
+ new AugmentationInfo { Name = "Increased Spell Duration", IntId = 238, Repeatable = 5, Trainer = "Nawamara Ujio", Location = "Mayoi" }, // AugmentationIncreasedSpellDuration
+ new AugmentationInfo { Name = "Faster HP Regen", IntId = 237, Repeatable = 2, Trainer = "Alison Dulane", Location = "Bandit Castle" }, // AugmentationFasterRegen
+ new AugmentationInfo { Name = "5% Experience Increase", IntId = 234, Repeatable = 1, Trainer = "Rickard Dumalia", Location = "Silyun" } // AugmentationBonusXp
+ },
+ ["Salvage Augs"] = new List
+ {
+ new AugmentationInfo { Name = "Specialized Weapon Tinkering", IntId = 228, Repeatable = 1, Trainer = "Lenor Turk", Location = "Cragstone" }, // AugmentationSpecializeWeaponTinkering
+ new AugmentationInfo { Name = "Specialized Armor Tinkering", IntId = 226, Repeatable = 1, Trainer = "Joshun Felden", Location = "Cragstone" }, // AugmentationSpecializeArmorTinkering
+ new AugmentationInfo { Name = "Specialized Item Tinkering", IntId = 225, Repeatable = 1, Trainer = "Brienne Carlus", Location = "Cragstone" }, // AugmentationSpecializeItemTinkering
+ new AugmentationInfo { Name = "Specialized Magic Item Tinkering", IntId = 227, Repeatable = 1, Trainer = "Burrell Sammrun", Location = "Cragstone" }, // AugmentationSpecializeMagicItemTinkering
+ new AugmentationInfo { Name = "Specialized Salvaging", IntId = 224, Repeatable = 1, Trainer = "Robert Crow", Location = "Cragstone" }, // AugmentationSpecializeSalvaging
+ new AugmentationInfo { Name = "25% More Salvage", IntId = 235, Repeatable = 4, Trainer = "Kris Cennis", Location = "Cragstone" }, // AugmentationBonusSalvage
+ new AugmentationInfo { Name = "5% Imbue Chance", IntId = 236, Repeatable = 1, Trainer = "Lug", Location = "Oolutanga's Refuge" } // AugmentationBonusImbueChance
+ },
+ ["Stat Augs"] = new List
+ {
+ new AugmentationInfo { Name = "All Stats", IntId = 217, Repeatable = 10, Trainer = "", Location = "" }, // AugmentationInnateFamily
+ new AugmentationInfo { Name = "Strength", IntId = 218, Repeatable = 10, Trainer = "Fiun Luunere", Location = "Fiun Outpost" }, // AugmentationInnateStrength
+ new AugmentationInfo { Name = "Endurance", IntId = 219, Repeatable = 10, Trainer = "Fiun Ruun", Location = "Fiun Outpost" }, // AugmentationInnateEndurance
+ new AugmentationInfo { Name = "Coordination", IntId = 220, Repeatable = 10, Trainer = "Fiun Bayaas", Location = "Fiun Outpost" }, // AugmentationInnateCoordination
+ new AugmentationInfo { Name = "Quickness", IntId = 221, Repeatable = 10, Trainer = "Fiun Riish", Location = "Fiun Outpost" }, // AugmentationInnateQuickness
+ new AugmentationInfo { Name = "Focus", IntId = 222, Repeatable = 10, Trainer = "Fiun Vasherr", Location = "Fiun Outpost" }, // AugmentationInnateFocus
+ new AugmentationInfo { Name = "Self", IntId = 223, Repeatable = 10, Trainer = "Fiun Noress", Location = "Fiun Outpost" } // AugmentationInnateSelf
+ },
+ ["Resistance Augs"] = new List
+ {
+ new AugmentationInfo { Name = "All Resistances", IntId = 239, Repeatable = 2, Trainer = "", Location = "" }, // AugmentationResistanceFamily
+ new AugmentationInfo { Name = "Blunt", IntId = 242, Repeatable = 2, Trainer = "Nawamara Dia", Location = "Hebian-To" }, // AugmentationResistanceBlunt
+ new AugmentationInfo { Name = "Pierce", IntId = 241, Repeatable = 2, Trainer = "Kyujo Rujen", Location = "Hebian-To" }, // AugmentationResistancePierce
+ new AugmentationInfo { Name = "Slashing", IntId = 240, Repeatable = 2, Trainer = "Ilin Wis", Location = "Hebian-To" }, // AugmentationResistanceSlash
+ new AugmentationInfo { Name = "Fire", IntId = 244, Repeatable = 2, Trainer = "Rikshen Ri", Location = "Hebian-To" }, // AugmentationResistanceFire
+ new AugmentationInfo { Name = "Frost", IntId = 245, Repeatable = 2, Trainer = "Lu Bao", Location = "Hebian-To" }, // AugmentationResistanceFrost
+ new AugmentationInfo { Name = "Acid", IntId = 243, Repeatable = 2, Trainer = "Shujio Milao", Location = "Hebian-To" }, // AugmentationResistanceAcid
+ new AugmentationInfo { Name = "Lightning", IntId = 246, Repeatable = 2, Trainer = "Enli Yuo", Location = "Hebian-To" } // AugmentationResistanceLightning
+ }
+ };
+ }
+
+ private void InitializeLuminanceAuraData()
+ {
+ LuminanceAuraCategories = new Dictionary>
+ {
+ ["Nalicana Auras"] = new List
+ {
+ new LuminanceAuraInfo { Name = "+1 Aetheria Proc Rating", IntId = 338, Cap = 5 }, // LumAugSurgeChanceRating
+ new LuminanceAuraInfo { Name = "+1 Damage Reduction Rating", IntId = 334, Cap = 5 }, // LumAugDamageReductionRating
+ new LuminanceAuraInfo { Name = "+1 Crit Reduction Rating", IntId = 336, Cap = 5 }, // LumAugCritReductionRating
+ new LuminanceAuraInfo { Name = "+1 Damage Rating", IntId = 333, Cap = 5 }, // LumAugDamageRating
+ new LuminanceAuraInfo { Name = "+1 Crit Damage Rating", IntId = 335, Cap = 5 }, // LumAugCritDamageRating
+ new LuminanceAuraInfo { Name = "+1 Heal Rating", IntId = 342, Cap = 5 }, // LumAugHealingRating
+ new LuminanceAuraInfo { Name = "+1 Equipment Mana Rating", IntId = 339, Cap = 5 }, // LumAugItemManaUsage
+ new LuminanceAuraInfo { Name = "+1 Mana Stone Rating", IntId = 340, Cap = 5 }, // LumAugItemManaGain
+ new LuminanceAuraInfo { Name = "+1 Crafting Skills", IntId = 343, Cap = 5 }, // LumAugSkilledCraft
+ new LuminanceAuraInfo { Name = "+1 All Skills", IntId = 365, Cap = 10 } // LumAugAllSkills
+ },
+ ["Seer Auras"] = new List
+ {
+ new LuminanceAuraInfo { Name = "(Ka'hiri) +2 Specialized Skills", IntId = 344, Cap = 5, QuestFlag = "LoyalToKahiri" }, // LumAugSkilledSpec
+ new LuminanceAuraInfo { Name = "(Ka'hiri) +1 Damage Rating", IntId = 333, Cap = 5, QuestFlag = "LoyalToKahiri" }, // LumAugDamageRating
+ new LuminanceAuraInfo { Name = "(Shade of Lady Adja) +2 Specialized Skills", IntId = 344, Cap = 5, QuestFlag = "LoyalToShadeOfLadyAdja" }, // LumAugSkilledSpec
+ new LuminanceAuraInfo { Name = "(Shade of Lady Adja) +1 Damage Reduction Rating", IntId = 334, Cap = 5, QuestFlag = "LoyalToShadeOfLadyAdja" }, // LumAugDamageReductionRating
+ new LuminanceAuraInfo { Name = "(Liam of Gelid) +1 Damage Rating", IntId = 333, Cap = 5, QuestFlag = "LoyalToLiamOfGelid" }, // LumAugDamageRating
+ new LuminanceAuraInfo { Name = "(Liam of Gelid) +1 Crit Damage Rating", IntId = 335, Cap = 5, QuestFlag = "LoyalToLiamOfGelid" }, // LumAugCritDamageRating
+ new LuminanceAuraInfo { Name = "(Lord Tyragar) +1 Crit Reduction Rating", IntId = 336, Cap = 5, QuestFlag = "LoyalToLordTyragar" }, // LumAugCritReductionRating
+ new LuminanceAuraInfo { Name = "(Lord Tyragar) +1 Damage Reduction Rating", IntId = 334, Cap = 5, QuestFlag = "LoyalToLordTyragar" } // LumAugDamageReductionRating
+ }
+ };
+ }
+
+ private void InitializeRecallSpellData()
+ {
+ RecallSpells = new List
+ {
+ new RecallSpellInfo { Name = "Recall the Sanctuary", SpellId = 2023, IconId = 0, Category = "Basic Recalls" },
+ new RecallSpellInfo { Name = "Aerlinthe Recall", SpellId = 2041, IconId = 0, Category = "Island Recalls" },
+ new RecallSpellInfo { Name = "Mount Lethe Recall", SpellId = 2813, IconId = 0, Category = "Island Recalls" },
+ new RecallSpellInfo { Name = "Recall Aphus Lassel", SpellId = 2931, IconId = 0, Category = "Island Recalls" },
+ new RecallSpellInfo { Name = "Ulgrim's Recall", SpellId = 2941, IconId = 0, Category = "Special Recalls" },
+ new RecallSpellInfo { Name = "Recall to the Singularity Caul", SpellId = 2943, IconId = 0, Category = "Island Recalls" },
+ new RecallSpellInfo { Name = "Glenden Wood Recall", SpellId = 3865, IconId = 0, Category = "Town Recalls" },
+ new RecallSpellInfo { Name = "Bur Recall", SpellId = 4084, IconId = 0, Category = "Town Recalls" },
+ new RecallSpellInfo { Name = "Call of the Mhoire Forge", SpellId = 4128, IconId = 0, Category = "Special Recalls" },
+ new RecallSpellInfo { Name = "Paradox-touched Olthoi Infested Area Recall", SpellId = 4198, IconId = 0, Category = "Special Recalls" },
+ new RecallSpellInfo { Name = "Colosseum Recall", SpellId = 4213, IconId = 0, Category = "Special Recalls" },
+ new RecallSpellInfo { Name = "Return to the Keep", SpellId = 4214, IconId = 0, Category = "Special Recalls" },
+ new RecallSpellInfo { Name = "Gear Knight Invasion Area Camp Recall", SpellId = 5330, IconId = 0, Category = "Special Recalls" },
+ new RecallSpellInfo { Name = "Lost City of Neftet Recall", SpellId = 5541, IconId = 0, Category = "Special Recalls" },
+ new RecallSpellInfo { Name = "Rynthid Recall", SpellId = 6150, IconId = 0, Category = "Special Recalls" },
+ new RecallSpellInfo { Name = "Viridian Rise Recall", SpellId = 6321, IconId = 0, Category = "Special Recalls" },
+ new RecallSpellInfo { Name = "Viridian Rise Great Tree Recall", SpellId = 6322, IconId = 0, Category = "Special Recalls" }
+ };
+ }
+
+ private void InitializeSocietyQuestData()
+ {
+ SocietyQuests = new Dictionary>();
+ // TODO: Initialize society quest data from Lua
+ }
+
+ private void InitializeCharacterFlagData()
+ {
+ CharacterFlags = new Dictionary>
+ {
+ ["Additional Skill Credits"] = new List
+ {
+ new CharacterFlagInfo { Name = "+1 Skill Lum Aura", QuestFlag = "lumaugskillquest", InfoType = QuestInfoType.SolveCount },
+ new CharacterFlagInfo { Name = "+1 Skill Aun Ralirea", QuestFlag = "arantahkill1", InfoType = QuestInfoType.SolveCount },
+ new CharacterFlagInfo { Name = "+1 Skill Chasing Oswald", QuestFlag = "oswaldmanualcompleted", InfoType = QuestInfoType.SolveCount }
+ },
+ ["Aetheria"] = new List
+ {
+ new CharacterFlagInfo { Name = "Blue Aetheria (75)", QuestFlag = "efulcentermanafieldused", InfoType = QuestInfoType.StampCheck },
+ new CharacterFlagInfo { Name = "Yellow Aetheria (150)", QuestFlag = "efmlcentermanafieldused", InfoType = QuestInfoType.StampCheck },
+ new CharacterFlagInfo { Name = "Red Aetheria (225)", QuestFlag = "efllcentermanafieldused", InfoType = QuestInfoType.StampCheck }
+ }
+ // TODO: Add remaining character flag categories
+ };
+ }
+
+ private void InitializeCantripData()
+ {
+ Cantrips = new Dictionary>
+ {
+ ["Specialized Skills"] = new Dictionary(), // Dynamically populated
+ ["Trained Skills"] = new Dictionary(), // Dynamically populated
+ ["Attributes"] = new Dictionary(), // Dynamically populated when cantrips are detected
+ ["Protection Auras"] = new Dictionary
+ {
+ // Pre-populate all protection auras so they show as red when missing
+ ["Armor"] = new CantripInfo { Name = "Armor", Value = "N/A", Color = System.Drawing.Color.White },
+ ["Bludgeoning Ward"] = new CantripInfo { Name = "Bludgeoning Ward", Value = "N/A", Color = System.Drawing.Color.White },
+ ["Piercing Ward"] = new CantripInfo { Name = "Piercing Ward", Value = "N/A", Color = System.Drawing.Color.White },
+ ["Slashing Ward"] = new CantripInfo { Name = "Slashing Ward", Value = "N/A", Color = System.Drawing.Color.White },
+ ["Flame Ward"] = new CantripInfo { Name = "Flame Ward", Value = "N/A", Color = System.Drawing.Color.White },
+ ["Frost Ward"] = new CantripInfo { Name = "Frost Ward", Value = "N/A", Color = System.Drawing.Color.White },
+ ["Acid Ward"] = new CantripInfo { Name = "Acid Ward", Value = "N/A", Color = System.Drawing.Color.White },
+ ["Storm Ward"] = new CantripInfo { Name = "Storm Ward", Value = "N/A", Color = System.Drawing.Color.White }
+ }
+ };
+ }
+
+ #endregion
+
+ #region Refresh Methods
+ public void RefreshAll()
+ {
+ RefreshCachedData();
+ RefreshAugmentations();
+ RefreshLuminanceAuras();
+ RefreshRecallSpells();
+ RefreshSocietyQuests();
+ RefreshFacilityHubQuests();
+ RefreshCharacterFlags();
+ RefreshCantrips();
+ }
+
+ public void RefreshCachedData()
+ {
+ try
+ {
+ if (CoreManager.Current?.CharacterFilter?.Name != null)
+ {
+ // Update cached values
+ var character = CoreManager.Current.CharacterFilter;
+ lastUpdateTime = DateTime.Now;
+
+ // TODO: Implement cached data refresh
+ }
+ }
+ catch (Exception ex)
+ {
+ PluginCore.WriteToChat($"Error refreshing cached data: {ex.Message}");
+ }
+ }
+
+ public void RefreshAugmentations()
+ {
+ try
+ {
+ if (CoreManager.Current?.CharacterFilter?.Name == null) return;
+
+ var character = CoreManager.Current.CharacterFilter;
+
+ // Update augmentation values
+ foreach (var category in AugmentationCategories)
+ {
+ foreach (var aug in category.Value)
+ {
+ if (aug.IntId.HasValue)
+ {
+ // Get augmentation value from character data using DECAL API
+ try
+ {
+ if (CoreManager.Current?.CharacterFilter != null)
+ {
+ var characterFilter = CoreManager.Current.CharacterFilter;
+ var playerObject = CoreManager.Current.WorldFilter[characterFilter.Id];
+
+ if (playerObject != null)
+ {
+ // Use CharacterFilter.GetCharProperty for character properties
+ try
+ {
+ // DECAL API uses CharacterFilter.GetCharProperty for character properties
+ aug.CurrentValue = characterFilter.GetCharProperty(aug.IntId.Value);
+ }
+ catch
+ {
+ // Try alternative access using reflection
+ try
+ {
+ var valuesMethod = playerObject.GetType().GetMethod("Values", new Type[] { typeof(int) });
+ if (valuesMethod != null)
+ {
+ aug.CurrentValue = (int)valuesMethod.Invoke(playerObject, new object[] { aug.IntId.Value });
+ }
+ else
+ {
+ aug.CurrentValue = 0;
+ }
+ }
+ catch
+ {
+ aug.CurrentValue = 0;
+ }
+ }
+ }
+ else
+ {
+ aug.CurrentValue = 0;
+ }
+ }
+ else
+ {
+ aug.CurrentValue = 0;
+ }
+ }
+ catch
+ {
+ aug.CurrentValue = 0;
+ }
+ }
+ else
+ {
+ // Handle special case for Asheron's Lesser Benediction (inventory count)
+ if (aug.Name == "Asheron's Lesser Benediction")
+ {
+ // Count Asheron's Lesser Benediction items in inventory
+ aug.CurrentValue = CountAsheronsLesserBenediction();
+ }
+ }
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ PluginCore.WriteToChat($"Error refreshing augmentations: {ex.Message}");
+ }
+ }
+
+ private int CountAsheronsLesserBenediction()
+ {
+ try
+ {
+ int count = 0;
+
+ // Search inventory for Asheron's Lesser Benediction
+ foreach (WorldObject item in CoreManager.Current.WorldFilter.GetInventory())
+ {
+ if (item.Name.Contains("Asheron's Lesser Benediction"))
+ {
+ // Use stack size for item count (default to 1 for non-stackable items)
+ try
+ {
+ // Access stack size using reflection to avoid type issues
+ var stackMethod = item.GetType().GetMethod("Values", new Type[] { typeof(int) });
+ if (stackMethod != null)
+ {
+ int stackSize = (int)stackMethod.Invoke(item, new object[] { 12 }); // 12 = StackSize
+ count += Math.Max(1, stackSize);
+ }
+ else
+ {
+ count += 1; // Default to 1
+ }
+ }
+ catch
+ {
+ count += 1; // Default fallback
+ }
+ }
+ }
+
+ return count;
+ }
+ catch (Exception ex)
+ {
+ PluginCore.WriteToChat($"Error counting Asheron's Lesser Benediction: {ex.Message}");
+ return 0;
+ }
+ }
+
+ public void RefreshLuminanceAuras()
+ {
+ try
+ {
+ if (CoreManager.Current?.CharacterFilter?.Name == null) return;
+
+ var characterFilter = CoreManager.Current.CharacterFilter;
+
+ // Update luminance aura values
+ foreach (var category in LuminanceAuraCategories)
+ {
+ foreach (var aura in category.Value)
+ {
+ try
+ {
+ // Use CharacterFilter.GetCharProperty for luminance auras
+ aura.CurrentValue = characterFilter.GetCharProperty(aura.IntId);
+ }
+ catch
+ {
+ aura.CurrentValue = 0;
+ }
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ PluginCore.WriteToChat($"Error refreshing luminance auras: {ex.Message}");
+ }
+ }
+
+ public void RefreshRecallSpells()
+ {
+ try
+ {
+ if (CoreManager.Current?.CharacterFilter?.Name == null) return;
+
+ var characterFilter = CoreManager.Current.CharacterFilter;
+ int knownCount = 0;
+
+ // Check each recall spell to see if the character knows it
+ foreach (var recall in RecallSpells)
+ {
+ try
+ {
+ // Use DECAL API to check if the character knows this spell
+ recall.IsKnown = characterFilter.IsSpellKnown(recall.SpellId);
+ if (recall.IsKnown) knownCount++;
+
+ // Get spell icon from FileService if not already set
+ if (recall.IconId == 0)
+ {
+ recall.IconId = GetSpellIcon(recall.SpellId);
+ }
+ }
+ catch
+ {
+ recall.IsKnown = false;
+ }
+ }
+
+ }
+ catch (Exception ex)
+ {
+ PluginCore.WriteToChat($"Error refreshing recall spells: {ex.Message}");
+ }
+ }
+
+ private int GetSpellIcon(int spellId)
+ {
+ try
+ {
+ // Try to get real spell icon first (matches original Lua approach)
+ int realSpellIcon = GetRealSpellIcon(spellId);
+ if (realSpellIcon != 0)
+ {
+ return realSpellIcon;
+ }
+
+ // Fallback to known recall spell icons
+ var recallSpellIcons = new Dictionary
+ {
+ // Recall spell icons from AC spell data
+ [2023] = 2943, // Recall the Sanctuary
+ [2041] = 2814, // Aerlinthe Recall
+ [2813] = 2813, // Mount Lethe Recall
+ [2931] = 2931, // Recall Aphus Lassel
+ [2943] = 2943, // Recall to the Singularity Caul
+ [3865] = 3864, // Glenden Wood Recall
+ [4084] = 4084, // Bur Recall
+ [2941] = 2814, // Ulgrim's Recall (uses portal icon)
+ [4128] = 4128, // Call of the Mhoire Forge
+ [4198] = 4197, // Paradox-touched Olthoi Infested Area Recall
+ [4213] = 4213, // Colosseum Recall
+ [4214] = 4199, // Return to the Keep
+ [5330] = 5175, // Gear Knight Invasion Area Camp Recall
+ [5541] = 5541, // Lost City of Neftet Recall
+ [6150] = 6150, // Rynthid Recall
+ [6321] = 6321, // Viridian Rise Recall
+ [6322] = 6322 // Viridian Rise Great Tree Recall
+ };
+
+ if (recallSpellIcons.ContainsKey(spellId))
+ {
+ // Add offset for spell icons
+ return recallSpellIcons[spellId] + 0x6000000;
+ }
+
+ // Final fallback - default portal/recall icon
+ return 0x6002D14;
+ }
+ catch
+ {
+ return 0x6002D14; // Default icon on error
+ }
+ }
+
+ private int GetRealSpellIcon(int spellId)
+ {
+ try
+ {
+ // Method 1: Use DECAL FileService SpellTable directly (proper API approach)
+ try
+ {
+ var fileService = CoreManager.Current.Filter();
+ if (fileService?.SpellTable != null)
+ {
+ var spell = fileService.SpellTable.GetById(spellId);
+ if (spell != null)
+ {
+ // Use reflection to access the internal Spell_Class object
+ // DECAL's Spell wrapper has an internal m_pSpell field that contains the actual data
+ var spellType = spell.GetType();
+
+ // Try to get the internal spell object first
+ var internalSpellField = spellType.GetField("m_pSpell", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
+ if (internalSpellField != null)
+ {
+ var internalSpell = internalSpellField.GetValue(spell);
+ if (internalSpell != null)
+ {
+ // Now get the icon from the internal spell object
+ var internalType = internalSpell.GetType();
+
+ // Try icon properties on the internal object
+ string[] iconPropertyNames = { "Icon", "icon", "IconId", "iconId", "IconID", "iconID" };
+ foreach (var propName in iconPropertyNames)
+ {
+ try
+ {
+ // Try as property
+ var iconProperty = internalType.GetProperty(propName, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
+ if (iconProperty != null)
+ {
+ var iconValue = iconProperty.GetValue(internalSpell, null);
+ if (iconValue is int iconInt && iconInt > 0)
+ {
+ // Spell icons use raw values (no offset)
+ return iconInt;
+ }
+ else if (iconValue is uint iconUint && iconUint > 0)
+ {
+ return (int)iconUint;
+ }
+ }
+
+ // Try as field
+ var iconField = internalType.GetField(propName, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
+ if (iconField != null)
+ {
+ var iconValue = iconField.GetValue(internalSpell);
+ if (iconValue is int iconInt && iconInt > 0)
+ {
+ return iconInt;
+ }
+ else if (iconValue is uint iconUint && iconUint > 0)
+ {
+ return (int)iconUint;
+ }
+ }
+ }
+ catch
+ {
+ // Continue trying other property names
+ }
+ }
+ }
+ }
+
+ // Fallback: Try direct properties on the wrapper
+ string[] wrapperPropertyNames = { "Icon", "IconId", "IconID" };
+ foreach (var propName in wrapperPropertyNames)
+ {
+ var iconProperty = spellType.GetProperty(propName, BindingFlags.Public | BindingFlags.Instance);
+ if (iconProperty != null)
+ {
+ var iconValue = iconProperty.GetValue(spell, null);
+ if (iconValue is int iconId && iconId > 0)
+ {
+ return iconId;
+ }
+ }
+ }
+ }
+ }
+ }
+ catch
+ {
+ }
+
+ // Method 2: Use known spell icon mappings for cantrips
+ // These are based on AC spell icon IDs from spell data
+ var cantripSpellIcons = new Dictionary
+ {
+ // Strength cantrips - use Strength Self VIII icon
+ [2091] = 1332, // Major Strength
+ [4325] = 1332, // Epic Strength
+ [6107] = 1332, // Legendary Strength
+
+ // Endurance cantrips - use Endurance Self VIII icon
+ [2061] = 1354, // Major Endurance
+ [4226] = 1354, // Epic Endurance
+ [6104] = 1354, // Legendary Endurance
+
+ // Coordination cantrips - use Coordination Self VIII icon
+ [2059] = 1378, // Major Coordination
+ [4296] = 1378, // Epic Coordination
+ [6102] = 1378, // Legendary Coordination
+
+ // Quickness cantrips - use Quickness Self VIII icon
+ [2081] = 1409, // Major Quickness
+ [4319] = 1409, // Epic Quickness
+ [6106] = 1409, // Legendary Quickness
+
+ // Focus cantrips - use Focus Self VIII icon
+ [2067] = 1426, // Major Focus
+ [4304] = 1426, // Epic Focus
+ [6105] = 1426, // Legendary Focus
+
+ // Willpower/Self cantrips - use Willpower Self VIII icon
+ [2091] = 1450, // Major Willpower
+ [4329] = 1450, // Epic Willpower
+ [6101] = 1450, // Legendary Willpower
+
+ // Protection Auras - Armor - use Armor Self VIII icon
+ [2113] = 1316, // Major Armor
+ [4291] = 1316, // Epic Armor
+ [6095] = 1316, // Legendary Armor
+
+ // Protection Auras - Physical
+ [2245] = 1023, // Major Piercing Ward - use Blade Protection Self VIII
+ [4306] = 1023, // Epic Piercing Ward
+ [6096] = 1023, // Legendary Piercing Ward
+
+ [2244] = 1114, // Major Slashing Ward - use Piercing Protection Self VIII
+ [4321] = 1114, // Epic Slashing Ward
+ [6097] = 1114, // Legendary Slashing Ward
+
+ [2243] = 1138, // Major Bludgeoning Ward - use Bludgeoning Protection Self VIII
+ [4293] = 1138, // Epic Bludgeoning Ward
+ [6098] = 1138, // Legendary Bludgeoning Ward
+
+ // Protection Auras - Elemental
+ [2157] = 1096, // Major Frost Ward - use Cold Protection Self VIII
+ [4309] = 1096, // Epic Frost Ward
+ [6100] = 1096, // Legendary Frost Ward
+
+ [2158] = 1035, // Major Flame Ward - use Fire Protection Self VIII
+ [4294] = 1035, // Epic Flame Ward
+ [6099] = 1035, // Legendary Flame Ward
+
+ [2149] = 1078, // Major Acid Ward - use Acid Protection Self VIII
+ [4290] = 1078, // Epic Acid Ward
+ [6094] = 1078, // Legendary Acid Ward
+
+ [2159] = 1161, // Major Storm Ward - use Lightning Protection Self VIII
+ [4322] = 1161, // Epic Storm Ward
+ [6079] = 1161, // Legendary Storm Ward
+
+ // Magic Defense cantrips - use Magic Resistance Self VIII icon
+ [2249] = 610, // Major Magic Resistance
+ [4314] = 610, // Epic Magic Resistance
+ [6067] = 610, // Legendary Magic Resistance
+
+ // Melee Defense cantrips - use Invulnerability Self VIII icon
+ [2248] = 562, // Major Invulnerability
+ [4312] = 562, // Epic Invulnerability
+ [6051] = 562, // Legendary Invulnerability
+
+ // Missile Defense cantrips - use Impregnability Self VIII icon
+ [2247] = 1562, // Major Impregnability
+ [4311] = 1562, // Epic Impregnability
+ [6055] = 1562, // Legendary Impregnability
+
+ // Life Magic cantrips - use Life Magic Mastery Self VIII icon
+ [2156] = 610, // Major Life Magic Aptitude
+ [4700] = 610, // Epic Life Magic Aptitude
+ [6044] = 610, // Legendary Life Magic Aptitude
+
+ // War Magic cantrips - use War Magic Mastery Self VIII icon
+ [2183] = 634, // Major War Magic Aptitude
+ [4715] = 634, // Epic War Magic Aptitude
+ [6075] = 634, // Legendary War Magic Aptitude
+
+ // Creature Enchantment cantrips - use Creature Enchantment Mastery Self VIII icon
+ [2215] = 586, // Major Creature Enchantment Aptitude
+ [4689] = 586, // Epic Creature Enchantment Aptitude
+ [6042] = 586, // Legendary Creature Enchantment Aptitude
+
+ // Item Enchantment cantrips - use Item Enchantment Mastery Self VIII icon
+ [2249] = 658, // Major Item Enchantment Aptitude
+ [4697] = 658, // Epic Item Enchantment Aptitude
+ [6043] = 658, // Legendary Item Enchantment Aptitude
+
+ // Void Magic cantrips - use Void Magic Mastery Self VI icon (no VIII version)
+ [5427] = 5418, // Major Void Magic Aptitude
+ [5428] = 5418, // Epic Void Magic Aptitude
+ [5429] = 5418, // Legendary Void Magic Aptitude
+
+ // Weapon cantrips
+ [2223] = 522, // Major Heavy Weapon Aptitude - use Heavy Weapon Mastery Self VIII
+ [4624] = 522, // Epic Heavy Weapon Aptitude
+ [6073] = 522, // Legendary Heavy Weapon Aptitude
+
+ [2226] = 327, // Major Light Weapon Aptitude - use Light Weapon Mastery Self VI
+ [4639] = 327, // Epic Light Weapon Aptitude
+ [6074] = 327, // Legendary Light Weapon Aptitude
+
+ [2227] = 350, // Major Finesse Weapon Aptitude - use Finesse Weapon Mastery Self VI
+ [4638] = 350, // Epic Finesse Weapon Aptitude
+ [6072] = 350, // Legendary Finesse Weapon Aptitude
+
+ [2230] = 473, // Major Missile Weapon Aptitude - use Missile Weapon Mastery Self VI
+ [4713] = 473, // Epic Missile Weapon Aptitude
+ [6071] = 473, // Legendary Missile Weapon Aptitude
+
+ // Mana Conversion cantrips - use Mana Conversion Mastery Self VIII icon
+ [2152] = 658, // Major Mana Conversion Prowess
+ [4705] = 658, // Epic Mana Conversion Prowess
+ [6048] = 658, // Legendary Mana Conversion Prowess
+ };
+
+ if (cantripSpellIcons.ContainsKey(spellId))
+ {
+ int iconId = cantripSpellIcons[spellId];
+ // Add offset for spell icons to display correctly in VVS
+ // Based on MagTools pattern, spell icons need the offset for display
+ int finalIconId = iconId + 0x6000000;
+ return finalIconId;
+ }
+
+ return 0; // No real icon found
+ }
+ catch
+ {
+ return 0;
+ }
+ }
+
+ public void RefreshSocietyQuests()
+ {
+ try
+ {
+ // TODO: Implement society quest refresh
+ }
+ catch (Exception ex)
+ {
+ PluginCore.WriteToChat($"Error refreshing society quests: {ex.Message}");
+ }
+ }
+
+ public void RefreshFacilityHubQuests()
+ {
+ try
+ {
+ // TODO: Implement facility hub quest refresh
+ }
+ catch (Exception ex)
+ {
+ PluginCore.WriteToChat($"Error refreshing facility hub quests: {ex.Message}");
+ }
+ }
+
+ public void RefreshCharacterFlags()
+ {
+ try
+ {
+ // TODO: Implement character flag refresh
+ }
+ catch (Exception ex)
+ {
+ PluginCore.WriteToChat($"Error refreshing character flags: {ex.Message}");
+ }
+ }
+
+ public void RefreshCantrips()
+ {
+ try
+ {
+
+ if (CoreManager.Current?.CharacterFilter?.Name == null)
+ {
+ return;
+ }
+
+ var characterFilter = CoreManager.Current.CharacterFilter;
+ var playerObject = CoreManager.Current.WorldFilter[characterFilter.Id];
+
+ if (playerObject == null)
+ {
+ return;
+ }
+
+
+ // Clear dynamic skill lists
+ Cantrips["Specialized Skills"].Clear();
+ Cantrips["Trained Skills"].Clear();
+
+ // Populate skills dynamically based on character's actual training
+ PopulateCharacterSkills(characterFilter);
+
+ // Reset all cantrips to "N/A"
+ foreach (var category in Cantrips)
+ {
+ foreach (var cantrip in category.Value.Values)
+ {
+ cantrip.Value = "N/A";
+ cantrip.Color = System.Drawing.Color.White;
+ }
+ }
+
+ // Scan active spells for cantrips using CharacterFilter.Enchantments
+ var enchantments = characterFilter.Enchantments;
+ if (enchantments != null)
+ {
+ for (int i = 0; i < enchantments.Count; i++)
+ {
+ var ench = enchantments[i];
+ var spell = SpellManager.GetSpell(ench.SpellId);
+ if (spell != null && spell.CantripLevel != Mag.Shared.Spells.Spell.CantripLevels.None)
+ {
+ DetectCantrip(ench.SpellId);
+ }
+ }
+ }
+ else
+ {
+ }
+
+ // Compute final icon IDs for all cantrips after refresh
+ foreach (var category in Cantrips)
+ {
+ foreach (var cantrip in category.Value.Values)
+ {
+ cantrip.ComputedIconId = ComputeCantripIcon(cantrip);
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ PluginCore.WriteToChat($"Error refreshing cantrips: {ex.Message}");
+ }
+ }
+
+ private void PopulateCharacterSkills(CharacterFilter characterFilter)
+ {
+ try
+ {
+ // Map of skill IDs to skill names - based on DECAL CharFilterSkillType enumeration
+ // Reference: DECAL API documentation and AC development sources
+ var skillIdToName = new Dictionary
+ {
+ [1] = "Axe", // Retired weapon skill
+ [2] = "Bow", // Retired weapon skill
+ [3] = "Crossbow", // Retired weapon skill
+ [4] = "Dagger", // Retired weapon skill
+ [5] = "Mace", // Retired weapon skill
+ [6] = "Melee Defense", // Active defense skill
+ [7] = "Missile Defense", // Active defense skill
+ [8] = "Sling", // Retired weapon skill
+ [9] = "Spear", // Retired weapon skill
+ [10] = "Staff", // Retired weapon skill
+ [11] = "Sword", // Retired weapon skill
+ [12] = "Thrown Weapons", // Retired weapon skill
+ [13] = "Unarmed Combat", // Retired weapon skill
+ [14] = "Arcane Lore", // Active magic skill
+ [15] = "Magic Defense", // Active defense skill
+ [16] = "Mana Conversion", // Active magic skill
+ [17] = "Spellcraft", // Unused/Reserved
+ [18] = "Item Tinkering", // Active tinker skill
+ [19] = "Assess Person", // Active misc skill
+ [20] = "Deception", // Active misc skill
+ [21] = "Healing", // Active misc skill
+ [22] = "Jump", // Active misc skill
+ [23] = "Lockpick", // Active misc skill
+ [24] = "Run", // Active misc skill
+ [25] = "Awareness", // Unused/Reserved
+ [26] = "Arms and Armor Repair", // Unused/Reserved
+ [27] = "Assess Creature", // Active misc skill
+ [28] = "Weapon Tinkering", // Active tinker skill
+ [29] = "Armor Tinkering", // Active tinker skill
+ [30] = "Magic Item Tinkering", // Active tinker skill
+ [31] = "Creature Enchantment", // Active magic skill
+ [32] = "Item Enchantment", // Active magic skill
+ [33] = "Life Magic", // Active magic skill
+ [34] = "War Magic", // Active magic skill
+ [35] = "Leadership", // Active misc skill
+ [36] = "Loyalty", // Active misc skill
+ [37] = "Fletching", // Active tinker skill
+ [38] = "Alchemy", // Active tinker skill
+ [39] = "Cooking", // Active tinker skill
+ [40] = "Salvaging", // Active tinker skill
+ [41] = "Two Handed Combat", // Active weapon skill
+ [42] = "Gearcraft", // Retired tinker skill
+ [43] = "Void Magic", // Active magic skill
+ [44] = "Heavy Weapons", // Active weapon skill
+ [45] = "Light Weapons", // Active weapon skill
+ [46] = "Finesse Weapons", // Active weapon skill
+ [47] = "Missile Weapons", // Active weapon skill
+ [48] = "Shield", // Active weapon skill
+ [49] = "Dual Wield", // Active weapon skill
+ [50] = "Recklessness", // Active weapon skill
+ [51] = "Sneak Attack", // Active weapon skill
+ [52] = "Dirty Fighting", // Active weapon skill
+ [53] = "Threat Assessment", // Unused/Reserved
+ [54] = "Summoning" // Active magic skill
+ };
+
+ // Check each skill's training status
+ foreach (var kvp in skillIdToName)
+ {
+ int skillId = kvp.Key;
+ string skillName = kvp.Value;
+
+ try
+ {
+ // Get skill training status using CharacterFilter.Skills
+ var skillInfo = characterFilter.Skills[(Decal.Adapter.Wrappers.CharFilterSkillType)skillId];
+ if (skillInfo == null) continue;
+
+ // Apply skill name replacements for cantrips
+ if (SkillCantripReplacements.ContainsKey(skillId))
+ {
+ skillName = SkillCantripReplacements[skillId];
+ }
+
+ if (skillInfo.Training == Decal.Adapter.Wrappers.TrainingType.Specialized)
+ {
+ Cantrips["Specialized Skills"][skillName] = new CantripInfo
+ {
+ Name = skillName,
+ Value = "N/A",
+ Color = System.Drawing.Color.White
+ // IconId removed - will be set by spell icon when cantrips are detected
+ };
+ }
+ else if (skillInfo.Training == Decal.Adapter.Wrappers.TrainingType.Trained)
+ {
+ Cantrips["Trained Skills"][skillName] = new CantripInfo
+ {
+ Name = skillName,
+ Value = "N/A",
+ Color = System.Drawing.Color.White
+ // IconId removed - will be set by spell icon when cantrips are detected
+ };
+ }
+ }
+ catch
+ {
+ // Skill not available on this character, skip it
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ PluginCore.WriteToChat($"Error populating character skills: {ex.Message}");
+ }
+ }
+
+ private int? GetSkillIconId(int skillId)
+ {
+ try
+ {
+ var characterFilter = CoreManager.Current.CharacterFilter;
+ if (characterFilter == null)
+ {
+ return GetFallbackSkillIcon(skillId);
+ }
+
+ // Validate skillId range for DECAL API
+ if (skillId < 1 || skillId > 54)
+ {
+ return GetFallbackSkillIcon(skillId);
+ }
+
+ try
+ {
+ var skillInfo = characterFilter.Skills[(Decal.Adapter.Wrappers.CharFilterSkillType)skillId];
+ if (skillInfo == null)
+ {
+ return GetFallbackSkillIcon(skillId);
+ }
+
+
+ // Try to access skill icon via reflection (DECAL's SkillInfoWrapper.Dat property)
+ var skillType = skillInfo.GetType();
+
+ // Method 1: Try FileService SkillTable approach (most reliable)
+ int realIconId = GetRealSkillIconFromDat(skillId);
+ if (realIconId > 0)
+ {
+ return realIconId + 0x6000000;
+ }
+
+ // Method 2: Reflection on SkillInfoWrapper.Dat
+ var datProperty = skillType.GetProperty("Dat", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
+ if (datProperty != null)
+ {
+ var datObject = datProperty.GetValue(skillInfo, null);
+ if (datObject != null)
+ {
+ var datType = datObject.GetType();
+
+ // Try the exact property names from AC system
+ string[] iconPropertyNames = { "IconID", "Icon", "IconId", "uiGraphic", "GraphicID" };
+
+ foreach (var propName in iconPropertyNames)
+ {
+ var iconProperty = datType.GetProperty(propName, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
+ if (iconProperty != null)
+ {
+ var iconValue = iconProperty.GetValue(datObject, null);
+ if (iconValue != null)
+ {
+ if (iconValue is int iconId && iconId > 0)
+ {
+ return iconId + 0x6000000;
+ }
+ else if (iconValue is uint uiconId && uiconId > 0)
+ {
+ return (int)uiconId + 0x6000000;
+ }
+ }
+ }
+ else
+ {
+ // Try as field
+ var iconField = datType.GetField(propName, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
+ if (iconField != null)
+ {
+ var iconValue = iconField.GetValue(datObject);
+ if (iconValue != null)
+ {
+ if (iconValue is int iconId && iconId > 0)
+ {
+ return iconId + 0x6000000;
+ }
+ else if (iconValue is uint uiconId && uiconId > 0)
+ {
+ return (int)uiconId + 0x6000000;
+ }
+ }
+ }
+ }
+ }
+
+ foreach (var prop in datType.GetProperties(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance))
+ {
+ try
+ {
+ var val = prop.GetValue(datObject, null);
+ PluginCore.WriteToChat($" {prop.Name}: {val} ({prop.PropertyType.Name})");
+ }
+ catch (Exception ex)
+ {
+ PluginCore.WriteToChat($" {prop.Name}: ({prop.PropertyType.Name})");
+ }
+ }
+ }
+ else
+ {
+ }
+ }
+ else
+ {
+ }
+
+ // Method 3: Try direct properties on SkillInfoWrapper
+ string[] directPropertyNames = { "IconID", "Icon", "IconId", "GraphicID" };
+ foreach (var propName in directPropertyNames)
+ {
+ var iconProperty = skillType.GetProperty(propName, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
+ if (iconProperty != null)
+ {
+ var iconValue = iconProperty.GetValue(skillInfo, null);
+ if (iconValue is int iconId && iconId > 0)
+ {
+ return iconId + 0x6000000;
+ }
+ }
+ }
+ }
+ catch
+ {
+ }
+
+ // Fallback to predefined mapping
+ return GetFallbackSkillIcon(skillId);
+ }
+ catch
+ {
+ return GetFallbackSkillIcon(skillId);
+ }
+ }
+
+ private string GetSkillName(int skillId)
+ {
+ var skillNames = new Dictionary
+ {
+ [1] = "Axe", [2] = "Bow", [3] = "Crossbow", [4] = "Dagger", [5] = "Mace",
+ [6] = "Melee Defense", [7] = "Missile Defense", [8] = "Sling", [9] = "Spear", [10] = "Staff",
+ [11] = "Sword", [12] = "Thrown Weapons", [13] = "Unarmed Combat", [14] = "Arcane Lore", [15] = "Magic Defense",
+ [16] = "Mana Conversion", [17] = "Spellcraft", [18] = "Item Tinkering", [19] = "Assess Person", [20] = "Deception",
+ [21] = "Healing", [22] = "Jump", [23] = "Lockpick", [24] = "Run", [25] = "Awareness",
+ [26] = "Arms and Armor Repair", [27] = "Assess Creature", [28] = "Weapon Tinkering", [29] = "Armor Tinkering", [30] = "Magic Item Tinkering",
+ [31] = "Creature Enchantment", [32] = "Item Enchantment", [33] = "Life Magic", [34] = "War Magic", [35] = "Leadership",
+ [36] = "Loyalty", [37] = "Fletching", [38] = "Alchemy", [39] = "Cooking", [40] = "Salvaging",
+ [41] = "Two Handed Combat", [42] = "Gearcraft", [43] = "Void Magic", [44] = "Heavy Weapons", [45] = "Light Weapons",
+ [46] = "Finesse Weapons", [47] = "Missile Weapons", [48] = "Shield", [49] = "Dual Wield", [50] = "Recklessness",
+ [51] = "Sneak Attack", [52] = "Dirty Fighting", [53] = "Threat Assessment", [54] = "Summoning"
+ };
+
+ return skillNames.ContainsKey(skillId) ? skillNames[skillId] : $"Unknown({skillId})";
+ }
+
+ private int GetRealSkillIconFromDat(int skillId)
+ {
+ try
+ {
+ // Try using FileService SkillTable directly (similar to CharacterCreation.cs pattern)
+ var fileService = CoreManager.Current.Filter();
+ if (fileService?.SkillTable != null)
+ {
+ // Try to get skill data from the skill table
+ try
+ {
+ // Access SkillTable via reflection to get skill data
+ var skillTableType = fileService.SkillTable.GetType();
+
+ // Look for methods that can get skill by ID
+ var methods = skillTableType.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
+ foreach (var method in methods)
+ {
+ if (method.Name.Contains("Get") || method.Name.Contains("get") || method.Name == "Item")
+ {
+ var parameters = method.GetParameters();
+ if (parameters.Length == 1 && (parameters[0].ParameterType == typeof(int) || parameters[0].ParameterType == typeof(uint)))
+ {
+ try
+ {
+ var skillData = method.Invoke(fileService.SkillTable, new object[] { skillId });
+ if (skillData != null)
+ {
+
+ // Look for icon properties on the skill data
+ var skillDataType = skillData.GetType();
+ string[] iconProps = { "IconID", "Icon", "IconId", "GraphicID", "uiGraphic" };
+
+ foreach (var propName in iconProps)
+ {
+ var iconProp = skillDataType.GetProperty(propName, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
+ if (iconProp != null)
+ {
+ var iconValue = iconProp.GetValue(skillData, null);
+ if (iconValue is int iconInt && iconInt > 0)
+ {
+ return iconInt;
+ }
+ else if (iconValue is uint iconUint && iconUint > 0)
+ {
+ return (int)iconUint;
+ }
+ }
+ }
+ }
+ }
+ catch
+ {
+ // Method call failed, try next one
+ }
+ }
+ }
+ }
+ }
+ catch
+ {
+ }
+ }
+ else
+ {
+ }
+
+ return 0; // No icon found
+ }
+ catch
+ {
+ return 0;
+ }
+ }
+
+ private int? GetFallbackSkillIcon(int skillId)
+ {
+ // Use proven working icon IDs from the recall spells system
+ // These icons are confirmed to display correctly in VVS
+ var skillIconMap = new Dictionary
+ {
+ // Magic Skills - Use magical/mystical icons (from recalls system)
+ [14] = 0x6002D14, // Arcane Lore - Portal icon (default recall icon)
+ [16] = 0x60011F9, // Mana Conversion - Green circle (confirmed working)
+ [31] = 0x6002D14, // Creature Enchantment - Portal icon
+ [32] = 0x6002D14, // Item Enchantment - Portal icon
+ [33] = 0x60011F9, // Life Magic - Green circle (life/healing)
+ [34] = 0x60011F8, // War Magic - Red circle (destruction)
+ [43] = 0x600287A, // Void Magic - Gray dot (void)
+ [54] = 0x6002D14, // Summoning - Portal icon
+
+ // Combat Skills - Use distinct working icons
+ [41] = 0x60011F8, // Two Handed Combat - Red circle
+ [44] = 0x60011F8, // Heavy Weapons - Red circle
+ [45] = 0x60028FC, // Light Weapons - Up arrow (from recalls)
+ [46] = 0x60028FD, // Finesse Weapons - Down arrow (from recalls)
+ [47] = 0x60020B5, // Missile Weapons - Question mark (from recalls)
+ [48] = 0x600287A, // Shield - Gray dot
+ [49] = 0x60011F8, // Dual Wield - Red circle
+ [50] = 0x60011F8, // Recklessness - Red circle
+ [51] = 0x600287A, // Sneak Attack - Gray dot
+ [52] = 0x60011F8, // Dirty Fighting - Red circle
+
+ // Defense Skills - Use defensive icons
+ [6] = 0x600287A, // Melee Defense - Gray dot
+ [7] = 0x600287A, // Missile Defense - Gray dot
+ [15] = 0x600287A, // Magic Defense - Gray dot
+
+ // Misc Skills - Use varied working icons
+ [19] = 0x60020B5, // Assess Person - Question mark (from recalls)
+ [20] = 0x60020B5, // Deception - Question mark
+ [21] = 0x60011F9, // Healing - Green circle
+ [22] = 0x60028FC, // Jump - Up arrow (motion)
+ [23] = 0x60020B5, // Lockpick - Question mark
+ [24] = 0x60028FC, // Run - Up arrow (motion)
+ [27] = 0x60020B5, // Assess Creature - Question mark
+ [35] = 0x60020B5, // Leadership - Question mark
+ [36] = 0x60020B5, // Loyalty - Question mark
+
+ // Craft Skills - Use working craft icons
+ [18] = 0x600287A, // Item Tinkering - Gray dot
+ [28] = 0x600287A, // Weapon Tinkering - Gray dot
+ [29] = 0x600287A, // Armor Tinkering - Gray dot
+ [30] = 0x600287A, // Magic Item Tinkering - Gray dot
+ [37] = 0x600287A, // Fletching - Gray dot
+ [38] = 0x600287A, // Alchemy - Gray dot
+ [39] = 0x600287A, // Cooking - Gray dot
+ [40] = 0x600287A, // Salvaging - Gray dot
+
+ // Retired weapon skills - Use weapon-style icons
+ [1] = 0x60011F8, // Axe - Red circle
+ [2] = 0x60028FC, // Bow - Up arrow (projectile)
+ [3] = 0x60028FC, // Crossbow - Up arrow (projectile)
+ [4] = 0x60011F8, // Dagger - Red circle
+ [5] = 0x60011F8, // Mace - Red circle
+ [8] = 0x60028FC, // Sling - Up arrow (projectile)
+ [9] = 0x60011F8, // Spear - Red circle
+ [10] = 0x60011F8, // Staff - Red circle
+ [11] = 0x60011F8, // Sword - Red circle
+ [12] = 0x60028FC, // Thrown Weapons - Up arrow (projectile)
+ [13] = 0x60011F8 // Unarmed Combat - Red circle
+ };
+
+ if (skillIconMap.ContainsKey(skillId))
+ {
+ return skillIconMap[skillId];
+ }
+
+ // Final fallback to proven working icon from recalls system
+ return 0x6002D14; // Portal icon - confirmed working in recalls
+ }
+
+ private int ComputeCantripIcon(CantripInfo cantrip)
+ {
+ try
+ {
+ // Green circle for active cantrips, red circle for missing cantrips
+ if (cantrip.Value != "N/A")
+ {
+ return 0x60011F9; // Green circle - has cantrip
+ }
+ else
+ {
+ return 0x60011F8; // Red circle - missing cantrip
+ }
+ }
+ catch
+ {
+ return 0x60011F8; // Red circle on error
+ }
+ }
+
+ private void DetectCantrip(int spellId)
+ {
+ try
+ {
+ // Get spell name from SpellManager
+ string spellName = GetSpellName(spellId);
+ if (string.IsNullOrEmpty(spellName))
+ {
+ return;
+ }
+
+ // Debug output to see what spells we're processing
+
+ // Define cantrip levels and their patterns
+ var cantripPatterns = new Dictionary
+ {
+ ["Minor"] = ("Minor", System.Drawing.Color.White),
+ ["Moderate"] = ("Moderate", System.Drawing.Color.Green),
+ ["Major"] = ("Major", System.Drawing.Color.Blue),
+ ["Epic"] = ("Epic", System.Drawing.Color.Purple),
+ ["Legendary"] = ("Legendary", System.Drawing.Color.Orange)
+ };
+
+ // Check each cantrip level
+ foreach (var cantripPattern in cantripPatterns)
+ {
+ string pattern = cantripPattern.Key;
+ var (level, color) = cantripPattern.Value;
+
+ if (!spellName.StartsWith(pattern + " ")) continue;
+
+ // Remove the level prefix to get the skill/attribute name
+ string skillPart = spellName.Substring(pattern.Length + 1);
+
+ // Get the spell icon for this cantrip spell
+ int spellIconId = GetRealSpellIcon(spellId);
+ if (spellIconId == 0)
+ {
+ spellIconId = 0x6002D14; // Default fallback icon
+ }
+ else
+ {
+ }
+
+ // Try to match Protection Auras first (exact format: "Minor Armor", "Epic Bludgeoning Ward")
+ if (MatchProtectionAura(skillPart, level, color, spellIconId))
+ {
+ return;
+ }
+
+ // Try to match Attributes (exact format: "Minor Strength", "Epic Focus")
+ if (MatchAttribute(skillPart, level, color, spellIconId))
+ {
+ return;
+ }
+
+ // Try to match Skills using the replacement mappings
+ if (MatchSkill(skillPart, level, color, spellIconId))
+ {
+ return;
+ }
+
+ }
+ }
+ catch (Exception ex)
+ {
+ PluginCore.WriteToChat($"Error detecting cantrip for spell {spellId}: {ex.Message}");
+ }
+ }
+
+ private bool MatchProtectionAura(string skillPart, string level, System.Drawing.Color color, int spellIconId)
+ {
+ // Map AC cantrip spell names to protection aura names
+ var protectionMappings = new Dictionary
+ {
+ ["Armor"] = "Armor",
+ ["Bludgeoning Ward"] = "Bludgeoning Ward",
+ ["Piercing Ward"] = "Piercing Ward",
+ ["Slashing Ward"] = "Slashing Ward",
+ ["Flame Ward"] = "Flame Ward",
+ ["Frost Ward"] = "Frost Ward",
+ ["Cold Ward"] = "Frost Ward", // AC also uses "Cold Ward"
+ ["Acid Ward"] = "Acid Ward",
+ ["Storm Ward"] = "Storm Ward", // AC spell is "Storm Ward"
+ ["Lightning Ward"] = "Storm Ward", // AC also uses "Lightning Ward"
+
+ // Add more variations that might appear in AC spell names
+ ["Bludgeoning Protection"] = "Bludgeoning Ward",
+ ["Piercing Protection"] = "Piercing Ward",
+ ["Slashing Protection"] = "Slashing Ward",
+ ["Fire Protection"] = "Flame Ward",
+ ["Cold Protection"] = "Frost Ward",
+ ["Acid Protection"] = "Acid Ward",
+ ["Lightning Protection"] = "Storm Ward",
+
+ // Single word variations
+ ["Bludgeoning"] = "Bludgeoning Ward",
+ ["Piercing"] = "Piercing Ward",
+ ["Slashing"] = "Slashing Ward",
+ ["Fire"] = "Flame Ward",
+ ["Flame"] = "Flame Ward",
+ ["Cold"] = "Frost Ward",
+ ["Frost"] = "Frost Ward",
+ ["Acid"] = "Acid Ward",
+ ["Lightning"] = "Storm Ward",
+ ["Storm"] = "Storm Ward"
+ };
+
+ foreach (var mapping in protectionMappings)
+ {
+ if (skillPart.Equals(mapping.Key, StringComparison.OrdinalIgnoreCase))
+ {
+ // Create the cantrip entry if it doesn't exist
+ if (!Cantrips["Protection Auras"].ContainsKey(mapping.Value))
+ {
+ Cantrips["Protection Auras"][mapping.Value] = new CantripInfo
+ {
+ Name = mapping.Value,
+ Value = "N/A",
+ Color = System.Drawing.Color.White
+ };
+ }
+
+ var cantrip = Cantrips["Protection Auras"][mapping.Value];
+ if (cantrip.Value == "N/A" || IsHigherCantripLevel(level, cantrip.Value))
+ {
+ cantrip.Value = level;
+ cantrip.Color = color;
+ cantrip.SpellIconId = spellIconId; // Use the actual spell icon from the cantrip
+ }
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private bool MatchAttribute(string skillPart, string level, System.Drawing.Color color, int spellIconId)
+ {
+ // Clean the skill part - remove extra spaces and normalize
+ string cleanedSkillPart = skillPart.Trim();
+
+ var attributeMappings = new Dictionary
+ {
+ ["Strength"] = "Strength",
+ ["Endurance"] = "Endurance",
+ ["Coordination"] = "Coordination",
+ ["Quickness"] = "Quickness",
+ ["Focus"] = "Focus",
+ ["Self"] = "Willpower", // "Minor Self" -> Willpower
+ ["Willpower"] = "Willpower" // "Epic Willpower" -> Willpower
+ };
+
+
+ foreach (var mapping in attributeMappings)
+ {
+ if (cleanedSkillPart.Equals(mapping.Key, StringComparison.OrdinalIgnoreCase))
+ {
+
+ // Create the cantrip entry if it doesn't exist
+ if (!Cantrips["Attributes"].ContainsKey(mapping.Value))
+ {
+ Cantrips["Attributes"][mapping.Value] = new CantripInfo
+ {
+ Name = mapping.Value,
+ Value = "N/A",
+ Color = System.Drawing.Color.White
+ };
+ }
+
+ var cantrip = Cantrips["Attributes"][mapping.Value];
+ if (cantrip.Value == "N/A" || IsHigherCantripLevel(level, cantrip.Value))
+ {
+ cantrip.Value = level;
+ cantrip.Color = color;
+ cantrip.SpellIconId = spellIconId; // Use the actual spell icon from the cantrip
+ }
+ return true;
+ }
+ }
+
+ // Try more flexible matching - check if the cleaned skill part contains any of our attributes
+ foreach (var mapping in attributeMappings)
+ {
+ if (cleanedSkillPart.IndexOf(mapping.Key, StringComparison.OrdinalIgnoreCase) >= 0)
+ {
+
+ // Create the cantrip entry if it doesn't exist
+ if (!Cantrips["Attributes"].ContainsKey(mapping.Value))
+ {
+ Cantrips["Attributes"][mapping.Value] = new CantripInfo
+ {
+ Name = mapping.Value,
+ Value = "N/A",
+ Color = System.Drawing.Color.White
+ };
+ }
+
+ var cantrip = Cantrips["Attributes"][mapping.Value];
+ if (cantrip.Value == "N/A" || IsHigherCantripLevel(level, cantrip.Value))
+ {
+ cantrip.Value = level;
+ cantrip.Color = color;
+ cantrip.SpellIconId = spellIconId; // Use the actual spell icon from the cantrip
+ }
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private bool MatchSkill(string skillPart, string level, System.Drawing.Color color, int spellIconId)
+ {
+ // Map actual cantrip spell names to our skill names
+ var skillMappings = new Dictionary
+ {
+ // Defense skills (with AC spell name mappings)
+ ["Magic Resistance"] = "MagicResistance",
+ ["Invulnerability"] = "Invulnerability",
+ ["Impregnability"] = "Impgrenability", // Note: AC uses "Impregnability" not "Impgrenability"
+
+ // Weapon skills (with AC spell name patterns)
+ ["Heavy Weapon Aptitude"] = "HeavyWeapon",
+ ["Light Weapon Aptitude"] = "LightWeapon",
+ ["Finesse Weapon Aptitude"] = "FinesseWeapon",
+ ["Missile Weapon Aptitude"] = "MissileWeapon",
+
+ // Craft skills (with AC spell name patterns)
+ ["Alchemical Prowess"] = "Alchemy",
+ ["Arcane Prowess"] = "Arcane Lore",
+ ["Armor Tinkering Expertise"] = "Armor Tinkering",
+ ["Assess Creature"] = "Assess Creature",
+ ["Assess Person"] = "Assess Person",
+ ["Cooking Prowess"] = "Cooking",
+ ["Deception Prowess"] = "Deception",
+ ["Fletching Prowess"] = "Fletching",
+ ["Healing Prowess"] = "Healing",
+ ["Item Tinkering Expertise"] = "Item Tinkering",
+ ["Leadership"] = "Leadership",
+ ["Lockpick Prowess"] = "Lockpick",
+ ["Fealty"] = "Loyalty", // AC uses "Fealty" for Loyalty
+ ["Magic Item Tinkering Expertise"] = "Magic Item Tinkering",
+ ["Mana Conversion Prowess"] = "Mana Conversion",
+ ["Jumping Prowess"] = "Jump", // AC has Jump cantrips
+ ["Salvaging Aptitude"] = "Salvaging",
+ ["Weapon Tinkering Expertise"] = "Weapon Tinkering",
+
+ // Magic schools (with AC spell name patterns)
+ ["War Magic Aptitude"] = "War Magic",
+ ["Life Magic Aptitude"] = "Life Magic",
+ ["Creature Enchantment Aptitude"] = "Creature Enchantment",
+ ["Item Enchantment Aptitude"] = "Item Enchantment",
+ ["Void Magic Aptitude"] = "Void Magic",
+ ["Summoning Prowess"] = "Summoning",
+
+ // Combat skills
+ ["Two Handed Combat Aptitude"] = "Two Handed Combat",
+ ["Dual Wield Aptitude"] = "Dual Wield",
+ ["Shield Aptitude"] = "Shield",
+ ["Sneak Attack Prowess"] = "Sneak Attack",
+ ["Dirty Fighting Prowess"] = "Dirty Fighting",
+ ["Recklessness"] = "Recklessness"
+ };
+
+ foreach (var mapping in skillMappings)
+ {
+ if (skillPart.Equals(mapping.Key, StringComparison.OrdinalIgnoreCase))
+ {
+ // Check both specialized and trained skills
+ foreach (var category in new[] { "Specialized Skills", "Trained Skills" })
+ {
+ if (Cantrips[category].ContainsKey(mapping.Value))
+ {
+ var cantrip = Cantrips[category][mapping.Value];
+ if (cantrip.Value == "N/A" || IsHigherCantripLevel(level, cantrip.Value))
+ {
+ cantrip.Value = level;
+ cantrip.Color = color;
+ cantrip.SpellIconId = spellIconId; // Use the actual spell icon instead of skill icon
+ cantrip.IconId = null; // Clear any skill icon reference
+ }
+ return true;
+ }
+ }
+ }
+ }
+ return false;
+ }
+
+ private string GetSpellName(int spellId)
+ {
+ try
+ {
+ // Use our existing SpellManager that was already working
+ var spell = SpellManager.GetSpell(spellId);
+ if (spell != null)
+ {
+ return spell.Name;
+ }
+ return "";
+ }
+ catch
+ {
+ return "";
+ }
+ }
+
+ private bool IsHigherCantripLevel(string newLevel, string currentLevel)
+ {
+ var levels = new Dictionary
+ {
+ ["Minor"] = 1,
+ ["Moderate"] = 2,
+ ["Major"] = 3,
+ ["Epic"] = 4,
+ ["Legendary"] = 5
+ };
+
+ if (!levels.ContainsKey(newLevel) || !levels.ContainsKey(currentLevel))
+ return false;
+
+ return levels[newLevel] > levels[currentLevel];
+ }
+
+ private void TestCantripDetection()
+ {
+ try
+ {
+ // Test real AC cantrip spell names to verify the detection logic
+ var testSpells = new[]
+ {
+ "Major Strength", // Attribute cantrip
+ "Epic Coordination", // Attribute cantrip
+ "Legendary Focus", // Attribute cantrip
+ "Major Willpower", // Willpower attribute
+ "Major Invulnerability", // Melee Defense skill
+ "Major Impregnability", // Missile Defense skill
+ "Major Alchemical Prowess", // Alchemy skill
+ "Major Arcane Prowess", // Arcane Lore skill
+ "Epic Life Magic Aptitude", // Life Magic skill
+ "Major Fealty", // Loyalty skill
+ "Major Armor", // Protection aura
+ "Epic Flame Ward", // Protection aura
+ "Legendary Storm Ward" // Protection aura
+ };
+
+ foreach (var spellName in testSpells)
+ {
+ TestDetectCantripByName(spellName);
+ }
+ }
+ catch (Exception ex)
+ {
+ PluginCore.WriteToChat($"Error in test cantrip detection: {ex.Message}");
+ }
+ }
+
+ private void TestDetectCantripByName(string spellName)
+ {
+ try
+ {
+ // Simulate the detection logic with a fake spell name
+
+ // Define cantrip levels and their patterns
+ var cantripPatterns = new Dictionary
+ {
+ ["Minor"] = ("Minor", System.Drawing.Color.White),
+ ["Moderate"] = ("Moderate", System.Drawing.Color.Green),
+ ["Major"] = ("Major", System.Drawing.Color.Blue),
+ ["Epic"] = ("Epic", System.Drawing.Color.Purple),
+ ["Legendary"] = ("Legendary", System.Drawing.Color.Orange)
+ };
+
+ // Check each cantrip level
+ foreach (var cantripPattern in cantripPatterns)
+ {
+ string pattern = cantripPattern.Key;
+ var (level, color) = cantripPattern.Value;
+
+ if (!spellName.StartsWith(pattern + " ")) continue;
+
+ // Remove the level prefix to get the skill/attribute name
+ string skillPart = spellName.Substring(pattern.Length + 1);
+
+
+ // Get a test spell icon (use default for testing)
+ int testSpellIconId = 0x6002D14;
+
+ // Try to match Protection Auras first
+ if (MatchProtectionAura(skillPart, level, color, testSpellIconId))
+ {
+ return;
+ }
+
+ // Try to match Attributes
+ if (MatchAttribute(skillPart, level, color, testSpellIconId))
+ {
+ return;
+ }
+
+ // Try to match Skills
+ if (MatchSkill(skillPart, level, color, testSpellIconId))
+ {
+ return;
+ }
+
+ }
+ }
+ catch (Exception ex)
+ {
+ PluginCore.WriteToChat($"Error testing cantrip detection: {ex.Message}");
+ }
+ }
+
+ #region Weapon Data Structures
+ public class TrackedWeaponInfo
+ {
+ public string Name { get; set; }
+ public string Category { get; set; }
+ public string WeaponType { get; set; }
+ public string Status { get; set; }
+ public bool IsAcquired { get; set; }
+ public int ItemId { get; set; }
+ public int IconId { get; set; }
+ }
+
+ public Dictionary> WeaponCategories { get; private set; }
+ #endregion
+
+ public void RefreshWeapons()
+ {
+ try
+ {
+ if (CoreManager.Current?.CharacterFilter?.Name == null) return;
+
+ InitializeNewWeaponData();
+ CheckAcquiredWeapons();
+ }
+ catch (Exception ex)
+ {
+ PluginCore.WriteToChat($"Error refreshing weapons: {ex.Message}");
+ }
+ }
+
+ private void InitializeNewWeaponData()
+ {
+ WeaponCategories = new Dictionary>
+ {
+ ["Legendary Weapons"] = new List
+ {
+ new TrackedWeaponInfo { Name = "Bow of the Quiddity", Category = "Legendary Weapons", WeaponType = "Bow", ItemId = 23044, Status = "Unknown" },
+ new TrackedWeaponInfo { Name = "Sword of the Quiddity", Category = "Legendary Weapons", WeaponType = "Sword", ItemId = 23039, Status = "Unknown" },
+ new TrackedWeaponInfo { Name = "Mace of the Quiddity", Category = "Legendary Weapons", WeaponType = "Mace", ItemId = 23040, Status = "Unknown" },
+ new TrackedWeaponInfo { Name = "Atlatl of the Quiddity", Category = "Legendary Weapons", WeaponType = "Atlatl", ItemId = 23043, Status = "Unknown" },
+ new TrackedWeaponInfo { Name = "Staff of the Quiddity", Category = "Legendary Weapons", WeaponType = "Staff", ItemId = 23041, Status = "Unknown" },
+ new TrackedWeaponInfo { Name = "Dagger of the Quiddity", Category = "Legendary Weapons", WeaponType = "Dagger", ItemId = 23042, Status = "Unknown" }
+ },
+ ["Slayer Weapons"] = new List
+ {
+ new TrackedWeaponInfo { Name = "Shadowfire Isparian Bow", Category = "Slayer Weapons", WeaponType = "Bow", ItemId = 20636, Status = "Unknown" },
+ new TrackedWeaponInfo { Name = "Shadowfire Isparian Sword", Category = "Slayer Weapons", WeaponType = "Sword", ItemId = 20631, Status = "Unknown" },
+ new TrackedWeaponInfo { Name = "Shadowfire Isparian Staff", Category = "Slayer Weapons", WeaponType = "Staff", ItemId = 20633, Status = "Unknown" },
+ new TrackedWeaponInfo { Name = "Shadowfire Isparian Dagger", Category = "Slayer Weapons", WeaponType = "Dagger", ItemId = 20634, Status = "Unknown" },
+ new TrackedWeaponInfo { Name = "Shadowfire Isparian Axe", Category = "Slayer Weapons", WeaponType = "Axe", ItemId = 20635, Status = "Unknown" },
+ new TrackedWeaponInfo { Name = "Shadowfire Isparian Mace", Category = "Slayer Weapons", WeaponType = "Mace", ItemId = 20632, Status = "Unknown" }
+ },
+ ["Society Weapons"] = new List
+ {
+ new TrackedWeaponInfo { Name = "Radiant Blood Sword", Category = "Society Weapons", WeaponType = "Sword", ItemId = 32834, Status = "Unknown" },
+ new TrackedWeaponInfo { Name = "Celestial Hand Sword", Category = "Society Weapons", WeaponType = "Sword", ItemId = 32835, Status = "Unknown" },
+ new TrackedWeaponInfo { Name = "Eldritch Web Sword", Category = "Society Weapons", WeaponType = "Sword", ItemId = 32836, Status = "Unknown" },
+ new TrackedWeaponInfo { Name = "Radiant Blood Bow", Category = "Society Weapons", WeaponType = "Bow", ItemId = 32837, Status = "Unknown" },
+ new TrackedWeaponInfo { Name = "Celestial Hand Bow", Category = "Society Weapons", WeaponType = "Bow", ItemId = 32838, Status = "Unknown" },
+ new TrackedWeaponInfo { Name = "Eldritch Web Bow", Category = "Society Weapons", WeaponType = "Bow", ItemId = 32839, Status = "Unknown" }
+ },
+ ["Atlan Weapons"] = new List
+ {
+ new TrackedWeaponInfo { Name = "Atlan Sword", Category = "Atlan Weapons", WeaponType = "Sword", ItemId = 11648, Status = "Unknown" },
+ new TrackedWeaponInfo { Name = "Atlan Axe", Category = "Atlan Weapons", WeaponType = "Axe", ItemId = 11649, Status = "Unknown" },
+ new TrackedWeaponInfo { Name = "Atlan Mace", Category = "Atlan Weapons", WeaponType = "Mace", ItemId = 11650, Status = "Unknown" },
+ new TrackedWeaponInfo { Name = "Atlan Spear", Category = "Atlan Weapons", WeaponType = "Spear", ItemId = 11651, Status = "Unknown" },
+ new TrackedWeaponInfo { Name = "Atlan Staff", Category = "Atlan Weapons", WeaponType = "Staff", ItemId = 11652, Status = "Unknown" },
+ new TrackedWeaponInfo { Name = "Atlan Dagger", Category = "Atlan Weapons", WeaponType = "Dagger", ItemId = 11653, Status = "Unknown" }
+ }
+ };
+ }
+
+ private void CheckAcquiredWeapons()
+ {
+ try
+ {
+ // Get inventory items
+ var worldFilter = CoreManager.Current.WorldFilter;
+ var inventoryItems = worldFilter.GetInventory().Cast().ToList();
+
+ // Check each weapon category
+ foreach (var category in WeaponCategories)
+ {
+ foreach (var weapon in category.Value)
+ {
+ // Check if weapon is acquired by name matching
+ var foundItem = inventoryItems.FirstOrDefault(item =>
+ item.Name.Contains(weapon.Name) ||
+ weapon.Name.Contains(item.Name));
+
+ if (foundItem != null)
+ {
+ weapon.IsAcquired = true;
+ weapon.Status = "Acquired";
+ weapon.IconId = foundItem.Icon + 0x6000000;
+ }
+ else
+ {
+ weapon.IsAcquired = false;
+ weapon.Status = "Not Acquired";
+ weapon.IconId = 0x6002D14; // Default icon
+ }
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ PluginCore.WriteToChat($"Error checking acquired weapons: {ex.Message}");
+ }
+ }
+ #endregion
+
+ #region Cleanup
+ public void Dispose()
+ {
+ // Cleanup if needed
+ }
+ #endregion
+ }
+}
\ No newline at end of file
diff --git a/MosswartMassacre/FodyWeavers.xml b/MosswartMassacre/FodyWeavers.xml
new file mode 100644
index 0000000..8c6d208
--- /dev/null
+++ b/MosswartMassacre/FodyWeavers.xml
@@ -0,0 +1,9 @@
+
+
+
+
+ YamlDotNet
+ Newtonsoft.Json
+
+
+
\ No newline at end of file
diff --git a/MosswartMassacre/GameEventRouter.cs b/MosswartMassacre/GameEventRouter.cs
new file mode 100644
index 0000000..6bda3f9
--- /dev/null
+++ b/MosswartMassacre/GameEventRouter.cs
@@ -0,0 +1,55 @@
+using System;
+using Decal.Adapter;
+
+namespace MosswartMassacre
+{
+ ///
+ /// Routes EchoFilter.ServerDispatch network messages to the appropriate handlers.
+ /// Owns the routing of 0xF7B0 sub-events and 0x02CF to CharacterStats.
+ ///
+ internal class GameEventRouter
+ {
+ private readonly IPluginLogger _logger;
+
+ internal GameEventRouter(IPluginLogger logger)
+ {
+ _logger = logger;
+ }
+
+ internal void OnServerDispatch(object sender, NetworkMessageEventArgs e)
+ {
+ try
+ {
+ if (e.Message.Type == Constants.GameEventMessageType)
+ {
+ int eventId = (int)e.Message["event"];
+
+ if (eventId == Constants.AllegianceInfoEvent)
+ {
+ CharacterStats.ProcessAllegianceInfoMessage(e);
+ }
+ else if (eventId == Constants.LoginCharacterEvent)
+ {
+ CharacterStats.ProcessCharacterPropertyData(e);
+ }
+ else if (eventId == Constants.TitlesListEvent)
+ {
+ CharacterStats.ProcessTitlesMessage(e);
+ }
+ else if (eventId == Constants.SetTitleEvent)
+ {
+ CharacterStats.ProcessSetTitleMessage(e);
+ }
+ }
+ else if (e.Message.Type == Constants.PrivateUpdatePropertyInt64)
+ {
+ CharacterStats.ProcessPropertyInt64Update(e);
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger?.Log($"[CharStats] ServerDispatch error: {ex.Message}");
+ }
+ }
+ }
+}
diff --git a/MosswartMassacre/HttpCommandServer.cs b/MosswartMassacre/HttpCommandServer.cs
deleted file mode 100644
index 35a6d38..0000000
--- a/MosswartMassacre/HttpCommandServer.cs
+++ /dev/null
@@ -1,105 +0,0 @@
-using System;
-using System.Net;
-using System.Text;
-using System.Threading;
-using System.Threading.Tasks;
-using Decal.Adapter;
-
-namespace MosswartMassacre
-{
- public static class HttpCommandServer
- {
- private static HttpListener listener;
- private static CancellationTokenSource cts;
- private static bool isRunning = false;
-
- public static bool IsRunning => isRunning;
-
- public static void Start()
- {
- if (isRunning) return;
-
- try
- {
- listener = new HttpListener();
- listener.Prefixes.Add("http://localhost:8085/");
- listener.Start();
- cts = new CancellationTokenSource();
- Task.Run(() => ListenLoop(cts.Token));
-
- isRunning = true;
- PluginCore.WriteToChat("[HTTP] Server started on port 8085.");
- }
- catch (Exception ex)
- {
- PluginCore.WriteToChat("[HTTP] Error starting server: " + ex.Message);
- }
- }
-
- public static void Stop()
- {
- if (!isRunning) return;
-
- try
- {
- cts.Cancel();
- listener.Stop();
- listener.Close();
- listener = null;
- isRunning = false;
- PluginCore.WriteToChat("[HTTP] Server stopped.");
- }
- catch (Exception ex)
- {
- PluginCore.WriteToChat("[HTTP] Error stopping server: " + ex.Message);
- }
- }
-
- private static async Task ListenLoop(CancellationToken token)
- {
- while (!token.IsCancellationRequested)
- {
- HttpListenerContext context = null;
-
- try
- {
- context = await listener.GetContextAsync();
- }
- catch (HttpListenerException)
- {
- break; // Listener was stopped
- }
-
- if (context == null) continue;
-
- string requestBody = new System.IO.StreamReader(context.Request.InputStream).ReadToEnd();
-
- PluginCore.WriteToChat("[HTTP] Received request: " + requestBody);
-
- // Parse simple format: target=Name&command=/say hello
- string target = "";
- string command = "";
- foreach (var pair in requestBody.Split('&'))
- {
- var parts = pair.Split('=');
- if (parts.Length == 2)
- {
- if (parts[0] == "target") target = WebUtility.UrlDecode(parts[1]);
- else if (parts[0] == "command") command = WebUtility.UrlDecode(parts[1]);
- }
- }
-
- if (!string.IsNullOrWhiteSpace(target) && !string.IsNullOrWhiteSpace(command))
- {
- string tellCmd = $"/a {target} {command}";
- CoreManager.Current.Actions.InvokeChatParser(tellCmd);
- }
-
- byte[] buffer = Encoding.UTF8.GetBytes("Command received.");
- context.Response.ContentLength64 = buffer.Length;
- context.Response.OutputStream.Write(buffer, 0, buffer.Length);
- context.Response.OutputStream.Close();
- }
- }
- }
-}
diff --git a/MosswartMassacre/IGameStats.cs b/MosswartMassacre/IGameStats.cs
new file mode 100644
index 0000000..ba0cb6f
--- /dev/null
+++ b/MosswartMassacre/IGameStats.cs
@@ -0,0 +1,19 @@
+using System;
+
+namespace MosswartMassacre
+{
+ ///
+ /// Provides game statistics for WebSocket telemetry payloads.
+ /// Replaces direct static field access on PluginCore.
+ ///
+ public interface IGameStats
+ {
+ int TotalKills { get; }
+ double KillsPerHour { get; }
+ int SessionDeaths { get; }
+ int TotalDeaths { get; }
+ int CachedPrismaticCount { get; }
+ string CharTag { get; }
+ DateTime StatsStartTime { get; }
+ }
+}
diff --git a/MosswartMassacre/IPluginLogger.cs b/MosswartMassacre/IPluginLogger.cs
new file mode 100644
index 0000000..c9d4157
--- /dev/null
+++ b/MosswartMassacre/IPluginLogger.cs
@@ -0,0 +1,11 @@
+namespace MosswartMassacre
+{
+ ///
+ /// Interface for writing messages to the game chat window.
+ /// Eliminates direct PluginCore.WriteToChat() dependencies from manager classes.
+ ///
+ public interface IPluginLogger
+ {
+ void Log(string message);
+ }
+}
diff --git a/MosswartMassacre/InventoryMonitor.cs b/MosswartMassacre/InventoryMonitor.cs
new file mode 100644
index 0000000..1f60313
--- /dev/null
+++ b/MosswartMassacre/InventoryMonitor.cs
@@ -0,0 +1,184 @@
+using System;
+using System.Collections.Generic;
+using Decal.Adapter;
+using Decal.Adapter.Wrappers;
+
+namespace MosswartMassacre
+{
+ ///
+ /// Tracks Prismatic Taper inventory counts using event-driven delta math.
+ /// Avoids expensive inventory scans during gameplay.
+ ///
+ internal class InventoryMonitor
+ {
+ private readonly IPluginLogger _logger;
+ private readonly Dictionary _trackedTaperContainers = new Dictionary();
+ private readonly Dictionary _lastKnownStackSizes = new Dictionary();
+
+ internal int CachedPrismaticCount { get; private set; }
+ internal int LastPrismaticCount { get; private set; }
+
+ internal InventoryMonitor(IPluginLogger logger)
+ {
+ _logger = logger;
+ }
+
+ internal void Initialize()
+ {
+ try
+ {
+ LastPrismaticCount = CachedPrismaticCount;
+ CachedPrismaticCount = Utils.GetItemStackSize("Prismatic Taper");
+
+ _trackedTaperContainers.Clear();
+ _lastKnownStackSizes.Clear();
+
+ foreach (WorldObject wo in CoreManager.Current.WorldFilter.GetInventory())
+ {
+ if (wo.Name.Equals("Prismatic Taper", StringComparison.OrdinalIgnoreCase) &&
+ IsPlayerOwnedContainer(wo.Container))
+ {
+ int stackCount = wo.Values(LongValueKey.StackCount, 1);
+ _trackedTaperContainers[wo.Id] = wo.Container;
+ _lastKnownStackSizes[wo.Id] = stackCount;
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger?.Log($"[TAPER] Error initializing count: {ex.Message}");
+ CachedPrismaticCount = 0;
+ LastPrismaticCount = 0;
+ _trackedTaperContainers.Clear();
+ _lastKnownStackSizes.Clear();
+ }
+ }
+
+ internal void OnInventoryCreate(object sender, CreateObjectEventArgs e)
+ {
+ try
+ {
+ var item = e.New;
+ if (IsPlayerOwnedContainer(item.Container) &&
+ item.Name.Equals("Prismatic Taper", StringComparison.OrdinalIgnoreCase))
+ {
+ LastPrismaticCount = CachedPrismaticCount;
+ int stackCount = item.Values(LongValueKey.StackCount, 1);
+ CachedPrismaticCount += stackCount;
+
+ _trackedTaperContainers[item.Id] = item.Container;
+ _lastKnownStackSizes[item.Id] = stackCount;
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger?.Log($"[TAPER] Error in OnInventoryCreate: {ex.Message}");
+ }
+ }
+
+ internal void OnInventoryRelease(object sender, ReleaseObjectEventArgs e)
+ {
+ try
+ {
+ var item = e.Released;
+ if (item.Name.Equals("Prismatic Taper", StringComparison.OrdinalIgnoreCase))
+ {
+ if (_trackedTaperContainers.TryGetValue(item.Id, out int previousContainer))
+ {
+ if (IsPlayerOwnedContainer(previousContainer))
+ {
+ LastPrismaticCount = CachedPrismaticCount;
+ int stackCount = item.Values(LongValueKey.StackCount, 1);
+ CachedPrismaticCount -= stackCount;
+ }
+
+ _trackedTaperContainers.Remove(item.Id);
+ _lastKnownStackSizes.Remove(item.Id);
+ }
+ else
+ {
+ LastPrismaticCount = CachedPrismaticCount;
+ CachedPrismaticCount = Utils.GetItemStackSize("Prismatic Taper");
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger?.Log($"[TAPER] Error in OnInventoryRelease: {ex.Message}");
+ }
+ }
+
+ internal void OnInventoryChange(object sender, ChangeObjectEventArgs e)
+ {
+ try
+ {
+ var item = e.Changed;
+ if (item.Name.Equals("Prismatic Taper", StringComparison.OrdinalIgnoreCase))
+ {
+ bool isInPlayerContainer = IsPlayerOwnedContainer(item.Container);
+
+ if (isInPlayerContainer)
+ {
+ bool wasAlreadyTracked = _trackedTaperContainers.ContainsKey(item.Id);
+ _trackedTaperContainers[item.Id] = item.Container;
+
+ int currentStack = item.Values(LongValueKey.StackCount, 1);
+
+ if (!wasAlreadyTracked)
+ {
+ LastPrismaticCount = CachedPrismaticCount;
+ CachedPrismaticCount += currentStack;
+ }
+ else if (_lastKnownStackSizes.TryGetValue(item.Id, out int previousStack))
+ {
+ int stackDelta = currentStack - previousStack;
+ if (stackDelta != 0)
+ {
+ LastPrismaticCount = CachedPrismaticCount;
+ CachedPrismaticCount += stackDelta;
+ }
+ }
+
+ _lastKnownStackSizes[item.Id] = currentStack;
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger?.Log($"[TAPER] Error in OnInventoryChange: {ex.Message}");
+ }
+ }
+
+ internal void Cleanup()
+ {
+ _trackedTaperContainers.Clear();
+ _lastKnownStackSizes.Clear();
+ }
+
+ internal int TrackedTaperCount => _trackedTaperContainers.Count;
+ internal int KnownStackSizesCount => _lastKnownStackSizes.Count;
+
+ private static bool IsPlayerOwnedContainer(int containerId)
+ {
+ try
+ {
+ if (containerId == CoreManager.Current.CharacterFilter.Id)
+ return true;
+
+ WorldObject container = CoreManager.Current.WorldFilter[containerId];
+ if (container != null &&
+ container.ObjectClass == ObjectClass.Container &&
+ container.Container == CoreManager.Current.CharacterFilter.Id)
+ {
+ return true;
+ }
+
+ return false;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+ }
+}
diff --git a/MosswartMassacre/KillTracker.cs b/MosswartMassacre/KillTracker.cs
new file mode 100644
index 0000000..0541f51
--- /dev/null
+++ b/MosswartMassacre/KillTracker.cs
@@ -0,0 +1,176 @@
+using System;
+using System.Text.RegularExpressions;
+using System.Timers;
+
+namespace MosswartMassacre
+{
+ ///
+ /// Tracks kills, deaths, and kill rate calculations.
+ /// Owns the 1-second stats update timer.
+ ///
+ internal class KillTracker
+ {
+ private readonly IPluginLogger _logger;
+ private readonly Action _onStatsUpdated;
+ private readonly Action _onElapsedUpdated;
+
+ private int _totalKills;
+ private int _sessionDeaths;
+ private int _totalDeaths;
+ private double _killsPer5Min;
+ private double _killsPerHour;
+ private DateTime _lastKillTime = DateTime.Now;
+ private DateTime _statsStartTime = DateTime.Now;
+ private Timer _updateTimer;
+
+ // Kill message patterns — all 35+ patterns preserved exactly
+ private static readonly string[] KillPatterns = new string[]
+ {
+ @"^You flatten (?.+)'s body with the force of your assault!$",
+ @"^You bring (?.+) to a fiery end!$",
+ @"^You beat (?.+) to a lifeless pulp!$",
+ @"^You smite (?.+) mightily!$",
+ @"^You obliterate (?.+)!$",
+ @"^You run (?.+) through!$",
+ @"^You reduce (?.+) to a sizzling, oozing mass!$",
+ @"^You knock (?.+) into next Morningthaw!$",
+ @"^You split (?.+) apart!$",
+ @"^You cleave (?.+) in twain!$",
+ @"^You slay (?.+) viciously enough to impart death several times over!$",
+ @"^You reduce (?.+) to a drained, twisted corpse!$",
+ @"^Your killing blow nearly turns (?.+) inside-out!$",
+ @"^Your attack stops (?.+) cold!$",
+ @"^Your lightning coruscates over (?.+)'s mortal remains!$",
+ @"^Your assault sends (?.+) to an icy death!$",
+ @"^You killed (?.+)!$",
+ @"^The thunder of crushing (?.+) is followed by the deafening silence of death!$",
+ @"^The deadly force of your attack is so strong that (?.+)'s ancestors feel it!$",
+ @"^(?.+)'s seared corpse smolders before you!$",
+ @"^(?.+) is reduced to cinders!$",
+ @"^(?.+) is shattered by your assault!$",
+ @"^(?.+) catches your attack, with dire consequences!$",
+ @"^(?.+) is utterly destroyed by your attack!$",
+ @"^(?.+) suffers a frozen fate!$",
+ @"^(?.+)'s perforated corpse falls before you!$",
+ @"^(?.+) is fatally punctured!$",
+ @"^(?.+)'s death is preceded by a sharp, stabbing pain!$",
+ @"^(?.+) is torn to ribbons by your assault!$",
+ @"^(?.+) is liquified by your attack!$",
+ @"^(?.+)'s last strength dissolves before you!$",
+ @"^Electricity tears (?.+) apart!$",
+ @"^Blistered by lightning, (?.+) falls!$",
+ @"^(?.+)'s last strength withers before you!$",
+ @"^(?.+) is dessicated by your attack!$",
+ @"^(?.+) is incinerated by your assault!$"
+ };
+
+ internal int TotalKills => _totalKills;
+ internal double KillsPerHour => _killsPerHour;
+ internal double KillsPer5Min => _killsPer5Min;
+ internal int SessionDeaths => _sessionDeaths;
+ internal int TotalDeaths => _totalDeaths;
+ internal DateTime StatsStartTime => _statsStartTime;
+ internal DateTime LastKillTime => _lastKillTime;
+ internal int RareCount { get; set; }
+
+ /// Logger for chat output
+ /// Callback(totalKills, killsPer5Min, killsPerHour) for UI updates
+ /// Callback(elapsed) for UI elapsed time updates
+ internal KillTracker(IPluginLogger logger, Action onStatsUpdated, Action onElapsedUpdated)
+ {
+ _logger = logger;
+ _onStatsUpdated = onStatsUpdated;
+ _onElapsedUpdated = onElapsedUpdated;
+ }
+
+ internal void Start()
+ {
+ _updateTimer = new Timer(Constants.StatsUpdateIntervalMs);
+ _updateTimer.Elapsed += UpdateStats;
+ _updateTimer.Start();
+ }
+
+ internal void Stop()
+ {
+ if (_updateTimer != null)
+ {
+ _updateTimer.Stop();
+ _updateTimer.Dispose();
+ _updateTimer = null;
+ }
+ }
+
+ internal bool CheckForKill(string text)
+ {
+ if (IsKilledByMeMessage(text))
+ {
+ _totalKills++;
+ _lastKillTime = DateTime.Now;
+ CalculateKillsPerInterval();
+ _onStatsUpdated?.Invoke(_totalKills, _killsPer5Min, _killsPerHour);
+ return true;
+ }
+ return false;
+ }
+
+ internal void OnDeath()
+ {
+ _sessionDeaths++;
+ }
+
+ internal void SetTotalDeaths(int totalDeaths)
+ {
+ _totalDeaths = totalDeaths;
+ }
+
+ internal void RestartStats()
+ {
+ _totalKills = 0;
+ RareCount = 0;
+ _sessionDeaths = 0;
+ _statsStartTime = DateTime.Now;
+ _killsPer5Min = 0;
+ _killsPerHour = 0;
+
+ _logger?.Log($"Stats have been reset. Session deaths: {_sessionDeaths}, Total deaths: {_totalDeaths}");
+ _onStatsUpdated?.Invoke(_totalKills, _killsPer5Min, _killsPerHour);
+ }
+
+ private void UpdateStats(object sender, ElapsedEventArgs e)
+ {
+ try
+ {
+ TimeSpan elapsed = DateTime.Now - _statsStartTime;
+ _onElapsedUpdated?.Invoke(elapsed);
+
+ CalculateKillsPerInterval();
+ _onStatsUpdated?.Invoke(_totalKills, _killsPer5Min, _killsPerHour);
+ }
+ catch (Exception ex)
+ {
+ _logger?.Log("Error updating stats: " + ex.Message);
+ }
+ }
+
+ private void CalculateKillsPerInterval()
+ {
+ double minutesElapsed = (DateTime.Now - _statsStartTime).TotalMinutes;
+
+ if (minutesElapsed > 0)
+ {
+ _killsPer5Min = (_totalKills / minutesElapsed) * 5;
+ _killsPerHour = (_totalKills / minutesElapsed) * 60;
+ }
+ }
+
+ private bool IsKilledByMeMessage(string text)
+ {
+ foreach (string pattern in KillPatterns)
+ {
+ if (Regex.IsMatch(text, pattern))
+ return true;
+ }
+ return false;
+ }
+ }
+}
diff --git a/MosswartMassacre/LiveInventoryTracker.cs b/MosswartMassacre/LiveInventoryTracker.cs
new file mode 100644
index 0000000..4b3546b
--- /dev/null
+++ b/MosswartMassacre/LiveInventoryTracker.cs
@@ -0,0 +1,169 @@
+using System;
+using System.Collections.Generic;
+using Decal.Adapter;
+using Decal.Adapter.Wrappers;
+using Mag.Shared;
+
+namespace MosswartMassacre
+{
+ ///
+ /// Sends inventory delta events (add/remove/update) via WebSocket
+ /// whenever items change in the player's inventory.
+ ///
+ internal class LiveInventoryTracker
+ {
+ private readonly IPluginLogger _logger;
+ private readonly HashSet _trackedItemIds = new HashSet();
+
+ internal LiveInventoryTracker(IPluginLogger logger)
+ {
+ _logger = logger;
+ }
+
+ ///
+ /// Initialize tracking for all current inventory items.
+ /// Called after login or hot reload, after the full inventory dump.
+ ///
+ internal void Initialize()
+ {
+ _trackedItemIds.Clear();
+ try
+ {
+ foreach (WorldObject wo in CoreManager.Current.WorldFilter.GetInventory())
+ {
+ _trackedItemIds.Add(wo.Id);
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger?.Log($"[LiveInv] Error initializing: {ex.Message}");
+ }
+ }
+
+ internal void OnCreateObject(object sender, CreateObjectEventArgs e)
+ {
+ try
+ {
+ var item = e.New;
+ if (!IsPlayerInventory(item)) return;
+ if (_trackedItemIds.Contains(item.Id)) return;
+
+ _trackedItemIds.Add(item.Id);
+ var mwo = MyWorldObjectCreator.Create(item);
+ _ = WebSocket.SendInventoryDeltaAsync("add", mwo);
+
+ // Request appraisal if item needs full ID data (spells, combat stats, etc.)
+ if (!item.HasIdData && ObjectClassNeedsIdent(item.ObjectClass, item.Name))
+ {
+ CoreManager.Current.Actions.RequestId(item.Id);
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger?.Log($"[LiveInv] Error in OnCreate: {ex.Message}");
+ }
+ }
+
+ internal void OnReleaseObject(object sender, ReleaseObjectEventArgs e)
+ {
+ try
+ {
+ var item = e.Released;
+ if (!_trackedItemIds.Contains(item.Id)) return;
+
+ _trackedItemIds.Remove(item.Id);
+ _ = WebSocket.SendInventoryRemoveAsync(item.Id);
+ }
+ catch (Exception ex)
+ {
+ _logger?.Log($"[LiveInv] Error in OnRelease: {ex.Message}");
+ }
+ }
+
+ internal void OnChangeObject(object sender, ChangeObjectEventArgs e)
+ {
+ try
+ {
+ var item = e.Changed;
+ if (!IsPlayerInventory(item))
+ {
+ // Item left our inventory
+ if (_trackedItemIds.Contains(item.Id))
+ {
+ _trackedItemIds.Remove(item.Id);
+ _ = WebSocket.SendInventoryRemoveAsync(item.Id);
+ }
+ return;
+ }
+
+ if (!_trackedItemIds.Contains(item.Id))
+ {
+ // New item appeared via ChangeObject
+ _trackedItemIds.Add(item.Id);
+ var mwo = MyWorldObjectCreator.Create(item);
+ _ = WebSocket.SendInventoryDeltaAsync("add", mwo);
+
+ // Request appraisal if item needs full ID data
+ if (!item.HasIdData && ObjectClassNeedsIdent(item.ObjectClass, item.Name))
+ {
+ CoreManager.Current.Actions.RequestId(item.Id);
+ }
+ }
+ else
+ {
+ // Existing item changed (equip/unequip, stack change, container move)
+ var mwo = MyWorldObjectCreator.Create(item);
+ _ = WebSocket.SendInventoryDeltaAsync("update", mwo);
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger?.Log($"[LiveInv] Error in OnChange: {ex.Message}");
+ }
+ }
+
+ internal void Cleanup()
+ {
+ _trackedItemIds.Clear();
+ }
+
+ private static bool ObjectClassNeedsIdent(ObjectClass oc, string name)
+ {
+ return oc == ObjectClass.Armor
+ || oc == ObjectClass.Clothing
+ || oc == ObjectClass.MeleeWeapon
+ || oc == ObjectClass.MissileWeapon
+ || oc == ObjectClass.WandStaffOrb
+ || oc == ObjectClass.Jewelry
+ || (oc == ObjectClass.Gem && !string.IsNullOrEmpty(name) && name.Contains("Aetheria"))
+ || (oc == ObjectClass.Misc && !string.IsNullOrEmpty(name) && name.Contains("Essence"));
+ }
+
+ private static bool IsPlayerInventory(WorldObject item)
+ {
+ try
+ {
+ int containerId = item.Container;
+ int charId = CoreManager.Current.CharacterFilter.Id;
+
+ // Directly in character's inventory
+ if (containerId == charId) return true;
+
+ // In a side pack owned by the character
+ WorldObject container = CoreManager.Current.WorldFilter[containerId];
+ if (container != null &&
+ container.ObjectClass == ObjectClass.Container &&
+ container.Container == charId)
+ {
+ return true;
+ }
+
+ return false;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+ }
+}
diff --git a/MosswartMassacre/MainView.cs b/MosswartMassacre/MainView.cs
deleted file mode 100644
index 063e7f7..0000000
--- a/MosswartMassacre/MainView.cs
+++ /dev/null
@@ -1,96 +0,0 @@
-using System;
-using MyClasses.MetaViewWrappers;
-
-namespace MosswartMassacre
-{
- internal static class MainView
- {
- private static IView View;
- private static IStaticText lblTotalKills;
- private static IStaticText lblKillsPer5Min;
- private static IStaticText lblKillsPerHour;
- private static IStaticText lblElapsedTime;
- private static IStaticText lblRareCount;
- private static IButton btnRestart;
- private static IButton btnToggleRareMeta;
-
- public static void ViewInit()
- {
- try
- {
- // Load the view from the embedded XML resource
- View = MyClasses.MetaViewWrappers.ViewSystemSelector.CreateViewResource(
- PluginCore.MyHost, "MosswartMassacre.ViewXML.mainView.xml");
-
- // Get references to controls
- lblTotalKills = (IStaticText)View["lblTotalKills"];
- lblKillsPer5Min = (IStaticText)View["lblKillsPer5Min"];
- lblKillsPerHour = (IStaticText)View["lblKillsPerHour"];
- lblElapsedTime = (IStaticText)View["lblElapsedTime"];
- lblRareCount = (IStaticText)View["lblRareCount"];
- btnRestart = (IButton)View["btnRestart"];
- btnRestart.Hit += OnRestartClick;
- btnToggleRareMeta = (IButton)View["btnToggleRareMeta"];
- btnToggleRareMeta.Hit += OnToggleRareMetaClick;
- btnToggleRareMeta.Text = "Meta: ON";
-
- PluginCore.WriteToChat("View initialized.");
- }
- catch (Exception ex)
- {
- PluginCore.WriteToChat("Error initializing view: " + ex.Message);
- }
- }
-
- public static void ViewDestroy()
- {
- try
- {
- View.Dispose();
- PluginCore.WriteToChat("View destroyed.");
- btnRestart.Hit -= OnRestartClick;
- btnToggleRareMeta.Hit -= OnToggleRareMetaClick;
- }
- catch (Exception ex)
- {
- PluginCore.WriteToChat("Error destroying view: " + ex.Message);
- }
- }
-
- public static void UpdateKillStats(int totalKills, double killsPer5Min, double killsPerHour)
- {
- lblTotalKills.Text = $"Total Kills: {totalKills}";
- lblKillsPer5Min.Text = $"Kills per 5 Min: {killsPer5Min:F2}";
- lblKillsPerHour.Text = $"Kills per Hour: {killsPerHour:F2}";
- }
-
- public static void UpdateElapsedTime(TimeSpan elapsed)
- {
- int days = elapsed.Days;
- int hours = elapsed.Hours;
- int minutes = elapsed.Minutes;
- int seconds = elapsed.Seconds;
-
- if (days > 0)
- lblElapsedTime.Text = $"Time: {days}d {hours:D2}:{minutes:D2}:{seconds:D2}";
- else
- lblElapsedTime.Text = $"Time: {hours:D2}:{minutes:D2}:{seconds:D2}";
- }
- public static void UpdateRareCount(int rareCount)
- {
- lblRareCount.Text = $"Rare Count: {rareCount}";
- }
- private static void OnRestartClick(object sender, EventArgs e)
- {
- PluginCore.RestartStats();
- }
- private static void OnToggleRareMetaClick(object sender, EventArgs e)
- {
- PluginCore.ToggleRareMeta();
- }
- public static void SetRareMetaToggleState(bool enabled)
- {
- btnToggleRareMeta.Text = enabled ? "Meta: ON" : "Meta: OFF";
- }
- }
-}
diff --git a/MosswartMassacre/MosswartMassacre.csproj b/MosswartMassacre/MosswartMassacre.csproj
index ac9acd5..56d93d1 100644
--- a/MosswartMassacre/MosswartMassacre.csproj
+++ b/MosswartMassacre/MosswartMassacre.csproj
@@ -1,5 +1,6 @@
+
Debug
@@ -13,6 +14,8 @@
8.0
512
true
+
+
true
@@ -29,42 +32,206 @@
pdbonly
true
bin\Release\
- TRACE
+ TRACE;VVS_REFERENCED;DECAL_INTEROP
prompt
4
+ x86
+ true
+
+ lib\0Harmony.dll
+ False
+
+
+ ..\packages\Costura.Fody.5.7.0\lib\netstandard1.0\Costura.dll
+
lib\Decal.Adapter.dll
False
-
+
+ lib\Decal.FileService.dll
+
+
False
False
lib\Decal.Interop.Core.DLL
+ False
+
+
+ False
+ False
+ lib\Decal.Interop.Filters.DLL
+ False
False
False
lib\Decal.Interop.Inject.dll
+
+ False
+ False
+ lib\Decal.Interop.D3DService.DLL
+ False
+
+
+ False
+ False
+ lib\Decal.Interop.Input.DLL
+ False
+
+
+ ..\packages\Microsoft.Win32.Primitives.4.3.0\lib\net46\Microsoft.Win32.Primitives.dll
+ True
+ True
+
..\packages\Newtonsoft.Json.13.0.3\lib\net45\Newtonsoft.Json.dll
+
+ ..\packages\System.AppContext.4.3.0\lib\net463\System.AppContext.dll
+ True
+ True
+
+
+
+ ..\packages\System.Console.4.3.0\lib\net46\System.Console.dll
+ True
+ True
+
+
+ ..\packages\System.Diagnostics.DiagnosticSource.4.3.0\lib\net46\System.Diagnostics.DiagnosticSource.dll
+
+
+ ..\packages\System.Diagnostics.Tracing.4.3.0\lib\net462\System.Diagnostics.Tracing.dll
+ True
+ True
+
+
+ ..\packages\System.Globalization.Calendars.4.3.0\lib\net46\System.Globalization.Calendars.dll
+ True
+ True
+
+
+ ..\packages\System.IO.4.3.0\lib\net462\System.IO.dll
+ True
+ True
+
+
+ ..\packages\System.IO.Compression.4.3.0\lib\net46\System.IO.Compression.dll
+ True
+ True
+
+
+
+ ..\packages\System.IO.Compression.ZipFile.4.3.0\lib\net46\System.IO.Compression.ZipFile.dll
+ True
+ True
+
+
+ ..\packages\System.IO.FileSystem.4.3.0\lib\net46\System.IO.FileSystem.dll
+ True
+ True
+
+
+ ..\packages\System.IO.FileSystem.Primitives.4.3.0\lib\net46\System.IO.FileSystem.Primitives.dll
+ True
+ True
+
+
+ ..\packages\System.Linq.4.3.0\lib\net463\System.Linq.dll
+ True
+ True
+
+
+ ..\packages\System.Linq.Expressions.4.3.0\lib\net463\System.Linq.Expressions.dll
+ True
+ True
+
+
+ ..\packages\System.Net.Http.4.3.0\lib\net46\System.Net.Http.dll
+ True
+ True
+
+
+ ..\packages\System.Net.Sockets.4.3.0\lib\net46\System.Net.Sockets.dll
+ True
+ True
+
+
+ ..\packages\System.Reflection.4.3.0\lib\net462\System.Reflection.dll
+ True
+ True
+
+
+ ..\packages\System.Runtime.4.3.0\lib\net462\System.Runtime.dll
+ True
+ True
+
+
+ ..\packages\System.Runtime.Extensions.4.3.0\lib\net462\System.Runtime.Extensions.dll
+ True
+ True
+
+
+ ..\packages\System.Runtime.InteropServices.4.3.0\lib\net463\System.Runtime.InteropServices.dll
+ True
+ True
+
+
+ ..\packages\System.Runtime.InteropServices.RuntimeInformation.4.3.0\lib\net45\System.Runtime.InteropServices.RuntimeInformation.dll
+ True
+ True
+
+
+
+ ..\packages\System.Security.Cryptography.Algorithms.4.3.0\lib\net463\System.Security.Cryptography.Algorithms.dll
+ True
+ True
+
+
+ ..\packages\System.Security.Cryptography.Encoding.4.3.0\lib\net46\System.Security.Cryptography.Encoding.dll
+ True
+ True
+
+
+ ..\packages\System.Security.Cryptography.Primitives.4.3.0\lib\net46\System.Security.Cryptography.Primitives.dll
+ True
+ True
+
+
+ ..\packages\System.Security.Cryptography.X509Certificates.4.3.0\lib\net461\System.Security.Cryptography.X509Certificates.dll
+ True
+ True
+
+
+ ..\packages\System.Text.RegularExpressions.4.3.0\lib\net463\System.Text.RegularExpressions.dll
+ True
+ True
+
-
-
+
+ ..\packages\System.Xml.ReaderWriter.4.3.0\lib\net46\System.Xml.ReaderWriter.dll
+ True
+ True
+
+
False
- bin\Debug\utank2-i.dll
+ lib\utank2-i.dll
+
+
+ lib\VCS5.dll
lib\VirindiViewService.dll
@@ -74,28 +241,111 @@
+
+ Shared\Constants\BoolValueKey.cs
+
+
+ Shared\Constants\Dictionaries.cs
+
+
+ Shared\Spells\Spell.cs
+
+
+ Shared\Constants\DoubleValueKey.cs
+
+
+ Shared\Constants\EphemeralAttribute.cs
+
+
+ Shared\Constants\IntValueKey.cs
+
+
+ Shared\Constants\SendOnLoginAttribute.cs
+
+
+ Shared\Constants\ServerOnlyAttribute.cs
+
+
+ Shared\Constants\StringValueKey.cs
+
+
+ Shared\Debug.cs
+
+
+ Shared\DecalProxy.cs
+
+
+ Shared\MyWorldObject.cs
+
+
+ Shared\MyWorldObjectCreator.cs
+
+
+ Shared\PostMessageTools.cs
+
+
+ Shared\RateLimiter.cs
+
+
+ Shared\SerializableDictionary.cs
+
+
+ Shared\Settings\Setting.cs
+
+
+ Shared\Settings\SettingsFile.cs
+
+
+ Shared\User32.cs
+
+
+ Shared\Util.cs
+
+
+ Shared\VCS_Connector.cs
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
+
+
True
True
Resources.resx
-
-
-
-
-
+
+
+
+
+
+
+
+
@@ -104,11 +354,51 @@
+
+
+
+ Shared\Spells\Spells.csv
+
+
+
+ lib\Decal.dll
+ False
+
+
+ lib\decalnet.dll
+ True
+
+
+
+
+
+
+
+
+ This project references NuGet package(s) that are missing on this computer. Use NuGet Package Restore to download them. For more information, see http://go.microsoft.com/fwlink/?LinkID=322105. The missing file is {0}.
+
+
+
+
+
+
+
+
+
+ $([System.DateTime]::UtcNow.ToString("yyyy.M.d.HHmm"))
+ $(IntermediateOutputPath)CalVer.cs
+
+
+
+
+
+
\ No newline at end of file
diff --git a/MosswartMassacre/MossyInventory.cs b/MosswartMassacre/MossyInventory.cs
new file mode 100644
index 0000000..299b130
--- /dev/null
+++ b/MosswartMassacre/MossyInventory.cs
@@ -0,0 +1,336 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using Newtonsoft.Json;
+
+using Mag.Shared;
+
+using Decal.Adapter;
+using Decal.Adapter.Wrappers;
+using System.Diagnostics;
+using YamlDotNet.Serialization;
+
+namespace MosswartMassacre
+{
+ class MossyInventory : IDisposable
+ {
+
+ private string InventoryFileName
+ {
+ get
+ {
+ // 1) Character name
+ var characterName = CoreManager.Current.CharacterFilter.Name;
+
+ // 2) Plugin folder - handle hot reload scenarios
+ string pluginFolder;
+ if (!string.IsNullOrEmpty(PluginCore.AssemblyDirectory))
+ {
+ pluginFolder = PluginCore.AssemblyDirectory;
+ }
+ else
+ {
+ pluginFolder = Path.GetDirectoryName(
+ System.Reflection.Assembly
+ .GetExecutingAssembly()
+ .Location
+ );
+ }
+
+ // 3) Character-specific folder path
+ var characterFolder = Path.Combine(pluginFolder, characterName);
+
+ // 4) Ensure directory exists (can do it here, thread-safe for most single-user plugin cases)
+ if (!Directory.Exists(characterFolder))
+ Directory.CreateDirectory(characterFolder);
+
+ // 5) Return full path to the .json file inside the character folder
+ return Path.Combine(characterFolder, $"{characterName}.json");
+ }
+ }
+
+ public MossyInventory()
+ {
+ try
+ {
+ CoreManager.Current.CharacterFilter.LoginComplete += CharacterFilter_LoginComplete;
+ CoreManager.Current.WorldFilter.CreateObject += WorldFilter_CreateObject;
+ CoreManager.Current.WorldFilter.ChangeObject += WorldFilter_ChangeObject;
+ CoreManager.Current.CharacterFilter.Logoff += CharacterFilter_Logoff;
+ PluginCore.WriteToChat($"[INV] {InventoryFileName}");
+ PluginCore.WriteToChat("Started MOSSY!");
+ }
+ catch (Exception ex)
+ {
+ PluginCore.WriteToChat($"[INV] {ex}");
+ }
+ }
+
+ private bool disposed;
+ public void Dispose()
+ {
+ Dispose(true);
+ GC.SuppressFinalize(this);
+ }
+ protected virtual void Dispose(bool disposing)
+ {
+ if (disposed) return;
+ if (disposing)
+ {
+ CoreManager.Current.CharacterFilter.LoginComplete -= CharacterFilter_LoginComplete;
+ CoreManager.Current.WorldFilter.CreateObject -= WorldFilter_CreateObject;
+ CoreManager.Current.WorldFilter.ChangeObject -= WorldFilter_ChangeObject;
+ CoreManager.Current.CharacterFilter.Logoff -= CharacterFilter_Logoff;
+ }
+ disposed = true;
+ }
+
+ private bool loginComplete;
+ private bool loggedInAndWaitingForIdData;
+ private readonly List requestedIds = new List();
+
+ private void CharacterFilter_LoginComplete(object sender, EventArgs e)
+ {
+ try
+ {
+ loginComplete = true;
+
+ // Defensive check - settings might not be initialized yet due to event handler order
+ bool inventoryLogEnabled;
+ try
+ {
+ inventoryLogEnabled = PluginSettings.Instance.InventoryLog;
+ }
+ catch (InvalidOperationException)
+ {
+ PluginCore.WriteToChat("[INV] Settings not ready, skipping inventory check");
+ return;
+ }
+
+ if (!inventoryLogEnabled)
+ return;
+
+ if (!File.Exists(InventoryFileName))
+ {
+ PluginCore.WriteToChat("Requesting id information for all armor/weapon inventory. This will take a few minutes...");
+ foreach (WorldObject wo in CoreManager.Current.WorldFilter.GetInventory())
+ {
+ if (!wo.HasIdData && ObjectClassNeedsIdent(wo.ObjectClass, wo.Name))
+ CoreManager.Current.Actions.RequestId(wo.Id);
+ }
+ loggedInAndWaitingForIdData = true;
+ }
+ else
+ {
+ DumpInventoryToFile(true);
+ }
+ }
+ catch (Exception ex)
+ {
+ PluginCore.WriteToChat($"[INV] {ex}");
+ }
+ }
+
+ private void WorldFilter_CreateObject(object sender, CreateObjectEventArgs e)
+ {
+ if (!loginComplete) return;
+
+ try
+ {
+ if (!PluginSettings.Instance.InventoryLog) return;
+ }
+ catch (InvalidOperationException)
+ {
+ return; // Settings not ready, skip silently
+ }
+
+ if (!e.New.HasIdData && ObjectClassNeedsIdent(e.New.ObjectClass, e.New.Name)
+ && !requestedIds.Contains(e.New.Id)
+ && e.New.Container == CoreManager.Current.CharacterFilter.Id)
+ {
+ requestedIds.Add(e.New.Id);
+ CoreManager.Current.Actions.RequestId(e.New.Id);
+ }
+ }
+
+ private void WorldFilter_ChangeObject(object sender, ChangeObjectEventArgs e)
+ {
+ if (!loginComplete) return;
+
+ try
+ {
+ if (!PluginSettings.Instance.InventoryLog) return;
+ }
+ catch (InvalidOperationException)
+ {
+ return; // Settings not ready, skip silently
+ }
+
+ if (loggedInAndWaitingForIdData)
+ {
+ bool allHaveId = true;
+ foreach (WorldObject wo in CoreManager.Current.WorldFilter.GetInventory())
+ {
+ if (!wo.HasIdData && ObjectClassNeedsIdent(wo.ObjectClass, wo.Name))
+ {
+ allHaveId = false;
+ break;
+ }
+ }
+ if (allHaveId)
+ {
+ loggedInAndWaitingForIdData = false;
+ DumpInventoryToFile();
+ PluginCore.WriteToChat("Requesting id information for all armor/weapon inventory completed. Log file written.");
+ }
+ }
+ else
+ {
+ if (!e.Changed.HasIdData && ObjectClassNeedsIdent(e.Changed.ObjectClass, e.Changed.Name)
+ && !requestedIds.Contains(e.Changed.Id)
+ && e.Changed.Container == CoreManager.Current.CharacterFilter.Id)
+ {
+ requestedIds.Add(e.Changed.Id);
+ CoreManager.Current.Actions.RequestId(e.Changed.Id);
+ }
+ }
+
+ }
+
+ private void CharacterFilter_Logoff(object sender, Decal.Adapter.Wrappers.LogoffEventArgs e)
+ {
+ try
+ {
+ try
+ {
+ if (!PluginSettings.Instance.InventoryLog) return;
+ }
+ catch (InvalidOperationException)
+ {
+ return; // Settings not ready, skip silently
+ }
+ DumpInventoryToFile(true); // Request IDs if missing to ensure complete data
+ }
+ catch (Exception ex)
+ {
+ PluginCore.WriteToChat($"[INV] {ex}");
+ }
+ }
+
+ private void DumpInventoryToFile(bool requestIdsIfMissing = false)
+ {
+ var previouslySaved = new List();
+
+ if (File.Exists(InventoryFileName))
+ {
+ try
+ {
+ string oldJson = File.ReadAllText(InventoryFileName);
+ previouslySaved = JsonConvert.DeserializeObject>(oldJson)
+ ?? new List();
+ }
+ catch (Exception)
+ {
+ PluginCore.WriteToChat("Inventory file is corrupt.");
+ }
+ }
+
+ var currentList = new List();
+ foreach (WorldObject wo in CoreManager.Current.WorldFilter.GetInventory())
+ {
+ // Check to see if we already have some information for this item
+ foreach (var prev in previouslySaved)
+ {
+ if (prev.Id == wo.Id && prev.ObjectClass == (int)wo.ObjectClass)
+ {
+ // If neither our past nor our current item HadIdData, but it should, lets request it
+ if (requestIdsIfMissing && !prev.HasIdData && !wo.HasIdData && ObjectClassNeedsIdent(wo.ObjectClass, wo.Name))
+ {
+ CoreManager.Current.Actions.RequestId(wo.Id);
+ currentList.Add(MyWorldObjectCreator.Create(wo));
+ }
+ else
+ {
+ // Add the WorldObject to the MyWorldObject data so we have up to date information
+ currentList.Add(MyWorldObjectCreator.Combine(prev, wo));
+ }
+
+ goto end;
+ }
+ }
+
+ if (requestIdsIfMissing && !wo.HasIdData && ObjectClassNeedsIdent(wo.ObjectClass, wo.Name))
+ CoreManager.Current.Actions.RequestId(wo.Id);
+
+ currentList.Add(MyWorldObjectCreator.Create(wo));
+
+ end: ;
+ }
+
+ var fi = new FileInfo(InventoryFileName);
+ if (fi.Directory != null && !fi.Directory.Exists)
+ fi.Directory.Create();
+
+ string json = JsonConvert.SerializeObject(currentList, Formatting.Indented);
+ File.WriteAllText(InventoryFileName, json);
+
+ // Send full inventory via WebSocket
+ if (PluginCore.WebSocketEnabled)
+ {
+ _ = WebSocket.SendFullInventoryAsync(currentList);
+ PluginCore.WriteToChat("Inventory sent to MosswartOverlord");
+ }
+ }
+
+ private bool ObjectClassNeedsIdent(ObjectClass oc, string name)
+ {
+ return oc == ObjectClass.Armor
+ || oc == ObjectClass.Clothing
+ || oc == ObjectClass.MeleeWeapon
+ || oc == ObjectClass.MissileWeapon
+ || oc == ObjectClass.WandStaffOrb
+ || oc == ObjectClass.Jewelry
+ || (oc == ObjectClass.Gem && !string.IsNullOrEmpty(name) && name.Contains("Aetheria"))
+ || (oc == ObjectClass.Misc && !string.IsNullOrEmpty(name) && name.Contains("Essence"));
+ }
+
+ ///
+ /// Forces an inventory upload with ID requests - guarantees complete data
+ ///
+ public void ForceInventoryUpload()
+ {
+ try
+ {
+ // Check if inventory logging is enabled
+ try
+ {
+ if (!PluginSettings.Instance.InventoryLog)
+ {
+ PluginCore.WriteToChat("[INV] Inventory logging is disabled");
+ return;
+ }
+ }
+ catch (InvalidOperationException)
+ {
+ PluginCore.WriteToChat("[INV] Settings not ready");
+ return;
+ }
+
+ // Check if WebSocket is enabled
+ if (!PluginCore.WebSocketEnabled)
+ {
+ PluginCore.WriteToChat("[INV] WebSocket streaming is disabled");
+ return;
+ }
+
+ PluginCore.WriteToChat("[INV] Forcing inventory upload with ID requests...");
+ DumpInventoryToFile(true); // Request IDs if missing
+ PluginCore.WriteToChat("[INV] Inventory upload completed");
+ }
+ catch (Exception ex)
+ {
+ PluginCore.WriteToChat($"[INV] Force upload failed: {ex.Message}");
+ }
+ }
+ }
+}
diff --git a/MosswartMassacre/NavRoute.cs b/MosswartMassacre/NavRoute.cs
new file mode 100644
index 0000000..6f3ccfa
--- /dev/null
+++ b/MosswartMassacre/NavRoute.cs
@@ -0,0 +1,412 @@
+using System;
+using System.Collections.Generic;
+using System.Drawing;
+using System.IO;
+using Decal.Adapter;
+using Decal.Adapter.Wrappers;
+
+namespace MosswartMassacre
+{
+ public class NavWaypoint
+ {
+ public double NS { get; set; }
+ public double EW { get; set; }
+ public double Z { get; set; }
+ public int Type { get; set; }
+ public NavWaypoint Previous { get; set; }
+ }
+
+ public class NavRoute : IDisposable
+ {
+ private bool disposed = false;
+ private List waypoints = new List();
+ private List lineObjects = new List();
+ private Color routeColor;
+ private bool isVisible = false;
+
+ public string FilePath { get; private set; }
+ public string FileName => Path.GetFileNameWithoutExtension(FilePath);
+ public bool IsVisible => isVisible;
+ public int WaypointCount => waypoints.Count;
+
+ public NavRoute(string filePath, Color color)
+ {
+ FilePath = filePath;
+ routeColor = color;
+ }
+
+ public bool LoadFromFile()
+ {
+ try
+ {
+ ClearRoute();
+ waypoints.Clear();
+
+ if (!File.Exists(FilePath))
+ {
+ PluginCore.WriteToChat($"Nav file not found: {FilePath}");
+ return false;
+ }
+
+ PluginCore.WriteToChat($"Navigation: Loading {FileName}...");
+
+ using (StreamReader sr = File.OpenText(FilePath))
+ {
+ // Read header
+ string header = sr.ReadLine();
+ if (string.IsNullOrEmpty(header) || !header.StartsWith("uTank2 NAV"))
+ {
+ PluginCore.WriteToChat($"Navigation: Invalid file format - {FileName}");
+ return false;
+ }
+
+ // Read nav type
+ string navTypeLine = sr.ReadLine();
+ if (string.IsNullOrEmpty(navTypeLine) || !int.TryParse(navTypeLine.Trim(), out int navType))
+ {
+ PluginCore.WriteToChat($"Navigation: Failed to parse route type - {FileName}");
+ return false;
+ }
+
+ string navTypeDescription = "";
+ switch (navType)
+ {
+ case 0:
+ navTypeDescription = "Linear";
+ break;
+ case 1:
+ navTypeDescription = "Circular";
+ break;
+ case 2:
+ navTypeDescription = "Linear";
+ break;
+ case 3:
+ navTypeDescription = "Target (follow player/object)";
+ break;
+ case 4:
+ navTypeDescription = "Once";
+ break;
+ default:
+ navTypeDescription = $"Unknown ({navType})";
+ PluginCore.WriteToChat($"Navigation: Unknown route type {navType} in {FileName}");
+ break;
+ }
+
+ // Handle target nav (type 3) - follows a specific player/object
+ if (navType == 3)
+ {
+ if (sr.EndOfStream)
+ {
+ PluginCore.WriteToChat($"Navigation: Target route file is empty - {FileName}");
+ return false;
+ }
+
+ string targetName = sr.ReadLine();
+ if (sr.EndOfStream)
+ {
+ PluginCore.WriteToChat($"Navigation: Target route missing target ID - {FileName}");
+ return false;
+ }
+
+ string targetIdLine = sr.ReadLine();
+
+ PluginCore.WriteToChat($"Navigation: Target route '{targetName}' cannot be visualized");
+ return true; // Successfully loaded but can't visualize
+ }
+
+ // Read record count
+ string recordCountLine = sr.ReadLine();
+ if (string.IsNullOrEmpty(recordCountLine) || !int.TryParse(recordCountLine.Trim(), out int recordCount))
+ {
+ PluginCore.WriteToChat($"Navigation: Failed to parse waypoint count - {FileName}");
+ return false;
+ }
+
+ if (recordCount <= 0 || recordCount > 10000) // Sanity check
+ {
+ PluginCore.WriteToChat($"Navigation: Invalid waypoint count {recordCount} - {FileName}");
+ return false;
+ }
+
+ NavWaypoint previous = null;
+ int waypointsRead = 0;
+
+ while (!sr.EndOfStream && waypointsRead < recordCount)
+ {
+ // Read waypoint type
+ string waypointTypeLine = sr.ReadLine();
+
+ if (string.IsNullOrEmpty(waypointTypeLine) || !int.TryParse(waypointTypeLine.Trim(), out int waypointType))
+ {
+ PluginCore.WriteToChat($"Navigation: Failed to parse waypoint {waypointsRead + 1} in {FileName}");
+ break; // Skip this waypoint, don't fail entirely
+ }
+
+ // Read coordinates (all waypoint types have EW, NS, Z, Unknown)
+ string ewLine = sr.ReadLine();
+ string nsLine = sr.ReadLine();
+ string zLine = sr.ReadLine();
+ string unknownLine = sr.ReadLine(); // Unknown value (always 0)
+
+ if (string.IsNullOrEmpty(ewLine) || string.IsNullOrEmpty(nsLine) || string.IsNullOrEmpty(zLine) || string.IsNullOrEmpty(unknownLine))
+ {
+ PluginCore.WriteToChat($"Navigation: Missing coordinates at waypoint {waypointsRead + 1} in {FileName}");
+ break;
+ }
+
+ if (!double.TryParse(ewLine.Trim(), out double ew) ||
+ !double.TryParse(nsLine.Trim(), out double ns) ||
+ !double.TryParse(zLine.Trim(), out double z))
+ {
+ PluginCore.WriteToChat($"Navigation: Invalid coordinates at waypoint {waypointsRead + 1} in {FileName}");
+ break; // Skip this waypoint
+ }
+
+ var waypoint = new NavWaypoint
+ {
+ NS = ns,
+ EW = ew,
+ Z = z,
+ Type = waypointType,
+ Previous = previous
+ };
+
+ waypoints.Add(waypoint);
+ previous = waypoint;
+ waypointsRead++;
+
+ // Skip additional data based on waypoint type
+ if (!SkipWaypointData(sr, waypointType))
+ {
+ PluginCore.WriteToChat($"Navigation: Failed to parse waypoint {waypointsRead + 1} data in {FileName}");
+ break; // Don't continue if we can't parse properly
+ }
+ }
+
+ if (waypoints.Count > 0)
+ {
+ PluginCore.WriteToChat($"Navigation: Loaded {FileName} ({waypoints.Count} waypoints)");
+ }
+ else
+ {
+ PluginCore.WriteToChat($"Navigation: No valid waypoints found in {FileName}");
+ }
+
+ return waypoints.Count > 0;
+ }
+ }
+ catch (Exception ex)
+ {
+ PluginCore.WriteToChat($"Navigation: Error loading {FileName} - {ex.Message}");
+ return false;
+ }
+ }
+
+ private bool SkipWaypointData(StreamReader sr, int waypointType)
+ {
+ try
+ {
+ // Skip additional lines based on waypoint type (base 4 lines already read)
+ switch (waypointType)
+ {
+ case 0: // Point - no additional data (4 lines total)
+ break;
+ case 1: // Portal - 5 additional lines (9 lines total)
+ sr.ReadLine(); // Name
+ sr.ReadLine(); // ObjectClass
+ sr.ReadLine(); // "true"
+ sr.ReadLine(); // PortalNS
+ sr.ReadLine(); // PortalEW
+ sr.ReadLine(); // PortalZ
+ break;
+ case 2: // Recall - 1 additional line (5 lines total)
+ sr.ReadLine(); // RecallSpellId
+ break;
+ case 3: // Pause - 1 additional line (5 lines total)
+ sr.ReadLine(); // Pause milliseconds
+ break;
+ case 4: // ChatCommand - 1 additional line (5 lines total)
+ sr.ReadLine(); // Message
+ break;
+ case 5: // OpenVendor - 2 additional lines (6 lines total)
+ sr.ReadLine(); // Id
+ sr.ReadLine(); // Name
+ break;
+ case 6: // Portal2 - same as Portal (9 lines total)
+ sr.ReadLine(); // Name
+ sr.ReadLine(); // ObjectClass
+ sr.ReadLine(); // "true"
+ sr.ReadLine(); // PortalNS
+ sr.ReadLine(); // PortalEW
+ sr.ReadLine(); // PortalZ
+ break;
+ case 7: // UseNPC - 5 additional lines (9 lines total)
+ sr.ReadLine(); // Name
+ sr.ReadLine(); // ObjectClass
+ sr.ReadLine(); // "true"
+ sr.ReadLine(); // NpcEW
+ sr.ReadLine(); // NpcNS
+ sr.ReadLine(); // NpcZ
+ break;
+ case 8: // Checkpoint - no additional data (4 lines total)
+ break;
+ case 9: // Jump - 3 additional lines (7 lines total)
+ sr.ReadLine(); // Heading
+ sr.ReadLine(); // ShiftJump
+ sr.ReadLine(); // Milliseconds
+ break;
+ default:
+ // Unknown waypoint type - skip silently
+ break;
+ }
+ return true;
+ }
+ catch
+ {
+ // Silently handle parsing errors
+ return false;
+ }
+ }
+
+ public void Show()
+ {
+ if (isVisible) return;
+
+ if (waypoints.Count == 0)
+ {
+ PluginCore.WriteToChat($"Navigation: No waypoints to visualize in {FileName}");
+ return;
+ }
+
+ CreateLineObjects();
+ isVisible = true;
+ PluginCore.WriteToChat($"Navigation: Showing {FileName} ({waypoints.Count} waypoints)");
+ }
+
+ public void Hide()
+ {
+ if (!isVisible) return;
+
+ ClearRoute();
+ isVisible = false;
+ PluginCore.WriteToChat($"Navigation: Hidden {FileName}");
+ }
+
+ private void CreateLineObjects()
+ {
+ try
+ {
+ // Check D3DService availability
+ if (CoreManager.Current?.D3DService == null)
+ {
+ PluginCore.WriteToChat($"Navigation: 3D service unavailable");
+ return;
+ }
+
+ // Limit the number of lines to prevent lag
+ int maxLines = Math.Min(waypoints.Count - 1, 500); // Max 500 lines
+
+ int linesCreated = 0;
+ for (int i = 1; i <= maxLines; i++)
+ {
+ var current = waypoints[i];
+ var previous = waypoints[i - 1];
+
+ if (CreateLineBetweenWaypoints(previous, current))
+ {
+ linesCreated++;
+ }
+
+ // Add small delay every 50 lines to prevent UI freezing
+ if (i % 50 == 0)
+ {
+ System.Threading.Thread.Sleep(1);
+ }
+ }
+
+ if (waypoints.Count > 501)
+ {
+ PluginCore.WriteToChat($"Navigation: Large route - showing {maxLines} of {waypoints.Count} segments");
+ }
+ }
+ catch (Exception ex)
+ {
+ PluginCore.WriteToChat($"Navigation: Error creating visualization - {ex.Message}");
+ }
+ }
+
+ private bool CreateLineBetweenWaypoints(NavWaypoint from, NavWaypoint to)
+ {
+ try
+ {
+ // Calculate distance
+ double distance = Math.Sqrt(
+ Math.Pow((to.NS - from.NS) * 240, 2) +
+ Math.Pow((to.EW - from.EW) * 240, 2) +
+ Math.Pow((to.Z - from.Z) * 240, 2)
+ );
+
+ if (distance <= 0) return false;
+
+ // Create D3D line object
+ var lineObj = CoreManager.Current.D3DService.NewD3DObj();
+ if (lineObj == null) return false;
+
+ lineObj.SetShape(D3DShape.Cube);
+ lineObj.Color = routeColor.ToArgb();
+
+ // Position at midpoint between waypoints
+ float midNS = (float)(from.NS + to.NS) / 2;
+ float midEW = (float)(from.EW + to.EW) / 2;
+ float midZ = (float)((from.Z + to.Z) * 120) + 0.1f; // Slightly higher than UtilityBelt
+
+ lineObj.Anchor(midNS, midEW, midZ);
+
+ // Orient toward destination
+ float orientNS = (float)from.NS;
+ float orientEW = (float)from.EW;
+ float orientZ = (float)(from.Z * 240) + 0.1f;
+
+ lineObj.OrientToCoords(orientNS, orientEW, orientZ, true);
+
+ // Scale to create line effect
+ lineObj.ScaleX = 0.3f; // Slightly thicker than UtilityBelt
+ lineObj.ScaleZ = 0.3f;
+ lineObj.ScaleY = (float)distance;
+
+ lineObj.Visible = true;
+ lineObjects.Add(lineObj);
+
+ return true;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ private void ClearRoute()
+ {
+ foreach (var obj in lineObjects)
+ {
+ try
+ {
+ obj.Visible = false;
+ obj.Dispose();
+ }
+ catch { }
+ }
+ lineObjects.Clear();
+ }
+
+ public void Dispose()
+ {
+ if (!disposed)
+ {
+ ClearRoute();
+ waypoints.Clear();
+ disposed = true;
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/MosswartMassacre/NavVisualization.cs b/MosswartMassacre/NavVisualization.cs
new file mode 100644
index 0000000..824462f
--- /dev/null
+++ b/MosswartMassacre/NavVisualization.cs
@@ -0,0 +1,246 @@
+using System;
+using System.Collections.Generic;
+using System.Drawing;
+using System.IO;
+using System.Linq;
+using Decal.Adapter;
+using Microsoft.Win32;
+
+namespace MosswartMassacre
+{
+ public class NavVisualization : IDisposable
+ {
+ private bool disposed = false;
+ private NavRoute currentRoute = null;
+ private string vtankProfilesDirectory = "";
+ private List availableNavFiles = new List();
+
+ // Default comparison route color (red)
+ private readonly Color comparisonRouteColor = Color.FromArgb(255, 255, 100, 100);
+
+ public bool IsEnabled { get; private set; } = false;
+ public bool HasRouteLoaded => currentRoute != null && currentRoute.WaypointCount > 0;
+ public string CurrentRouteFile => currentRoute?.FileName ?? "None";
+ public List AvailableNavFiles => availableNavFiles.ToList();
+
+ public event EventHandler RouteChanged;
+
+ public NavVisualization()
+ {
+ InitializeVTankDirectory();
+ RefreshNavFileList();
+ }
+
+ private void InitializeVTankDirectory()
+ {
+ try
+ {
+ // First, check if user has configured a custom path
+ if (!string.IsNullOrEmpty(PluginSettings.Instance?.VTankProfilesPath))
+ {
+ vtankProfilesDirectory = PluginSettings.Instance.VTankProfilesPath;
+ return;
+ }
+
+ // Try to get VTank directory from Windows Registry (same method as UtilityBelt)
+ var defaultPath = @"C:\Games\VirindiPlugins\VirindiTank\";
+ try
+ {
+ var regKey = Registry.LocalMachine.OpenSubKey("Software\\Decal\\Plugins\\{642F1F48-16BE-48BF-B1D4-286652C4533E}");
+ if (regKey != null)
+ {
+ var profilePath = regKey.GetValue("ProfilePath")?.ToString();
+ if (!string.IsNullOrEmpty(profilePath))
+ {
+ vtankProfilesDirectory = profilePath;
+ return;
+ }
+ }
+ }
+ catch
+ {
+ }
+
+ // Fall back to default path
+ vtankProfilesDirectory = defaultPath;
+ // Using default path - user can configure in Settings if needed
+ }
+ catch (Exception ex)
+ {
+ PluginCore.WriteToChat($"[NavViz] Error finding VTank directory: {ex.Message}");
+ vtankProfilesDirectory = "";
+ }
+ }
+
+ ///
+ /// Scan VTank directory for .nav files and populate available routes list
+ /// Filters out follow files and temporary files, sorts alphabetically
+ ///
+ public void RefreshNavFileList()
+ {
+ // Re-initialize directory in case settings changed
+ InitializeVTankDirectory();
+
+ availableNavFiles.Clear();
+
+
+ if (string.IsNullOrEmpty(vtankProfilesDirectory))
+ {
+ PluginCore.WriteToChat("VTank directory not configured. Set path in Settings tab.");
+ return;
+ }
+
+ if (!Directory.Exists(vtankProfilesDirectory))
+ {
+ PluginCore.WriteToChat($"VTank directory not found: {vtankProfilesDirectory}");
+ return;
+ }
+
+ try
+ {
+ // Get all files and filter for .nav files only, excluding follow/temporary files
+ var allFiles = Directory.GetFiles(vtankProfilesDirectory);
+ var navFiles = allFiles
+ .Where(file => Path.GetExtension(file).Equals(".nav", StringComparison.OrdinalIgnoreCase))
+ .Select(file => Path.GetFileNameWithoutExtension(file))
+ .Where(name => !string.IsNullOrEmpty(name) &&
+ !name.StartsWith("follow", StringComparison.OrdinalIgnoreCase) &&
+ !name.StartsWith("--", StringComparison.OrdinalIgnoreCase))
+ .OrderBy(name => name)
+ .ToList();
+
+ availableNavFiles.AddRange(navFiles);
+
+ // Only report summary - no need to spam chat with every file
+ if (navFiles.Count > 0)
+ {
+ PluginCore.WriteToChat($"Navigation: Found {navFiles.Count} route files");
+ }
+ }
+ catch (Exception ex)
+ {
+ PluginCore.WriteToChat($"Navigation: Error scanning files - {ex.Message}");
+ }
+ }
+
+ ///
+ /// Load a specific navigation route file for visualization
+ /// Clears current route if "None" specified, otherwise loads .nav file
+ ///
+ /// Name of .nav file (without extension) or "None"
+ /// True if route loaded successfully, false otherwise
+ public bool LoadRoute(string navFileName)
+ {
+ try
+ {
+ // Clear current route
+ if (currentRoute != null)
+ {
+ currentRoute.Dispose();
+ currentRoute = null;
+ }
+
+ if (string.IsNullOrEmpty(navFileName) || navFileName == "None")
+ {
+ RouteChanged?.Invoke(this, EventArgs.Empty);
+ return true;
+ }
+
+ string fullPath = Path.Combine(vtankProfilesDirectory, navFileName + ".nav");
+
+ if (!File.Exists(fullPath))
+ {
+ PluginCore.WriteToChat($"Navigation file '{navFileName}' not found");
+ return false;
+ }
+
+ currentRoute = new NavRoute(fullPath, comparisonRouteColor);
+
+ if (!currentRoute.LoadFromFile())
+ {
+ currentRoute.Dispose();
+ currentRoute = null;
+ return false;
+ }
+
+ // Show route if visualization is enabled
+ if (IsEnabled)
+ {
+ currentRoute.Show();
+ }
+
+ RouteChanged?.Invoke(this, EventArgs.Empty);
+ return true;
+ }
+ catch (Exception ex)
+ {
+ PluginCore.WriteToChat($"Navigation: Failed to load '{navFileName}' - {ex.Message}");
+ return false;
+ }
+ }
+
+ ///
+ /// Enable or disable navigation route visualization in 3D world
+ /// Shows/hides the currently loaded route based on enabled state
+ ///
+ /// True to show route lines, false to hide
+ public void SetEnabled(bool enabled)
+ {
+ // No change needed if already in desired state
+ if (IsEnabled == enabled) return;
+
+ IsEnabled = enabled;
+
+ if (currentRoute != null)
+ {
+ if (enabled)
+ {
+ currentRoute.Show();
+ }
+ else
+ {
+ currentRoute.Hide();
+ }
+ }
+ else
+ {
+ }
+
+ PluginCore.WriteToChat($"Navigation visualization {(enabled ? "enabled" : "disabled")}");
+ }
+
+ public void ToggleEnabled()
+ {
+ SetEnabled(!IsEnabled);
+ }
+
+ public string GetStatus()
+ {
+ if (currentRoute == null)
+ return "No route loaded";
+
+ string status = $"{currentRoute.FileName} ({currentRoute.WaypointCount} points)";
+ if (IsEnabled && currentRoute.IsVisible)
+ status += " - Visible";
+ else if (IsEnabled)
+ status += " - Hidden";
+ else
+ status += " - Disabled";
+
+ return status;
+ }
+
+ public void Dispose()
+ {
+ if (!disposed)
+ {
+ if (currentRoute != null)
+ {
+ currentRoute.Dispose();
+ currentRoute = null;
+ }
+ disposed = true;
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/MosswartMassacre/PluginCore.cs b/MosswartMassacre/PluginCore.cs
index 95e1858..b78ebb6 100644
--- a/MosswartMassacre/PluginCore.cs
+++ b/MosswartMassacre/PluginCore.cs
@@ -1,59 +1,332 @@
using System;
using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
using System.Net;
using System.Runtime.InteropServices;
-using System.Text.RegularExpressions;
+using System.Threading.Tasks;
using System.Timers;
using Decal.Adapter;
using Decal.Adapter.Wrappers;
+using MosswartMassacre.Views;
+using Mag.Shared.Constants;
namespace MosswartMassacre
{
[FriendlyName("Mosswart Massacre")]
- public class PluginCore : PluginBase
+ public class PluginCore : PluginBase, IPluginLogger, IGameStats
{
+ // Hot Reload Support Properties
+ private static string _assemblyDirectory = null;
+ public static string AssemblyDirectory
+ {
+ get
+ {
+ if (_assemblyDirectory == null)
+ {
+ try
+ {
+ _assemblyDirectory = System.IO.Path.GetDirectoryName(typeof(PluginCore).Assembly.Location);
+ }
+ catch
+ {
+ _assemblyDirectory = Environment.CurrentDirectory;
+ }
+ }
+ return _assemblyDirectory;
+ }
+ set
+ {
+ _assemblyDirectory = value;
+ }
+ }
+ public static bool IsHotReload { get; set; }
+
internal static PluginHost MyHost;
- internal static int totalKills = 0;
- internal static int rareCount = 0;
- internal static DateTime lastKillTime = DateTime.Now;
- internal static double killsPer5Min = 0;
- internal static double killsPerHour = 0;
- internal static DateTime statsStartTime = DateTime.Now;
- internal static Timer updateTimer;
- public static bool RareMetaEnabled { get; set; } = true;
- public static bool RemoteCommandsEnabled { get; set; } = false;
- public static bool HttpServerEnabled { get; set; } = false;
+ // Static bridge properties for VVSTabbedMainView (reads from manager instances)
+ private static InventoryMonitor _staticInventoryMonitor;
+ internal static int cachedPrismaticCount => _staticInventoryMonitor?.CachedPrismaticCount ?? 0;
+ private static KillTracker _staticKillTracker;
+ internal static int totalKills => _staticKillTracker?.TotalKills ?? 0;
+ internal static double killsPerHour => _staticKillTracker?.KillsPerHour ?? 0;
+ internal static int sessionDeaths => _staticKillTracker?.SessionDeaths ?? 0;
+ internal static int totalDeaths => _staticKillTracker?.TotalDeaths ?? 0;
+ internal static DateTime statsStartTime => _staticKillTracker?.StatsStartTime ?? DateTime.Now;
+ internal static DateTime lastKillTime => _staticKillTracker?.LastKillTime ?? DateTime.Now;
+
+ // IGameStats explicit implementation (for WebSocket telemetry)
+ int IGameStats.TotalKills => _staticKillTracker?.TotalKills ?? 0;
+ double IGameStats.KillsPerHour => _staticKillTracker?.KillsPerHour ?? 0;
+ int IGameStats.SessionDeaths => _staticKillTracker?.SessionDeaths ?? 0;
+ int IGameStats.TotalDeaths => _staticKillTracker?.TotalDeaths ?? 0;
+ int IGameStats.CachedPrismaticCount => _staticInventoryMonitor?.CachedPrismaticCount ?? 0;
+ string IGameStats.CharTag => CharTag;
+ DateTime IGameStats.StatsStartTime => _staticKillTracker?.StatsStartTime ?? DateTime.Now;
+
+ private static Timer vitalsTimer;
+ private static System.Windows.Forms.Timer commandTimer;
+ private static Timer characterStatsTimer;
+ private static Timer _updateCheckTimer;
+ private static readonly Queue pendingCommands = new Queue();
+ private static RareTracker _staticRareTracker;
+ public static bool RareMetaEnabled
+ {
+ get => _staticRareTracker?.RareMetaEnabled ?? true;
+ set { if (_staticRareTracker != null) _staticRareTracker.RareMetaEnabled = value; }
+ }
+
+ // VVS View Management
+ private static class ViewManager
+ {
+ public static void ViewInit()
+ {
+ Views.VVSTabbedMainView.ViewInit();
+ }
+
+ public static void ViewDestroy()
+ {
+ Views.VVSTabbedMainView.ViewDestroy();
+ }
+
+ public static void UpdateKillStats(int totalKills, double killsPer5Min, double killsPerHour)
+ {
+ Views.VVSTabbedMainView.UpdateKillStats(totalKills, killsPer5Min, killsPerHour);
+ }
+
+ public static void UpdateElapsedTime(TimeSpan elapsed)
+ {
+ Views.VVSTabbedMainView.UpdateElapsedTime(elapsed);
+ }
+
+ public static void UpdateRareCount(int rareCount)
+ {
+ Views.VVSTabbedMainView.UpdateRareCount(rareCount);
+ }
+
+ public static void SetRareMetaToggleState(bool enabled)
+ {
+ Views.VVSTabbedMainView.SetRareMetaToggleState(enabled);
+ }
+
+ public static void RefreshSettingsFromConfig()
+ {
+ Views.VVSTabbedMainView.RefreshSettingsFromConfig();
+ }
+
+ public static void RestoreWindowPosition()
+ {
+ Views.VVSTabbedMainView.RestorePosition();
+ }
+
+ public static void RefreshUpdateStatus()
+ {
+ Views.VVSTabbedMainView.RefreshUpdateStatus();
+ }
+ }
public static string CharTag { get; set; } = "";
- public static bool TelemetryEnabled { get; set; } = false;
- private static Queue rareMessageQueue = new Queue();
- private static DateTime _lastSent = DateTime.MinValue;
+ public static bool WebSocketEnabled { get; set; } = false;
+ public bool InventoryLogEnabled { get; set; } = false;
+ public static bool AggressiveChatStreamingEnabled { get; set; } = true;
+ private MossyInventory _inventoryLogger;
+ public static NavVisualization navVisualization;
+ public static ChestLooter chestLooter;
+
+ // Quest Management for always-on quest streaming
+ public static QuestManager questManager;
+
private static readonly Queue _chatQueue = new Queue();
+ // Managers
+ private KillTracker _killTracker;
+ private RareTracker _rareTracker;
+ private InventoryMonitor _inventoryMonitor;
+ private ChatEventRouter _chatEventRouter;
+ private GameEventRouter _gameEventRouter;
+ private QuestStreamingService _questStreamingService;
+ private CommandRouter _commandRouter;
+ private LiveInventoryTracker _liveInventoryTracker;
+
protected override void Startup()
{
try
{
- MyHost = Host;
+ // Set MyHost - for hot reload scenarios, Host might be null
+ if (Host != null)
+ {
+ MyHost = Host;
+ }
+ else if (MyHost == null)
+ {
+ // Hot reload fallback - this is okay, WriteToChat will handle it
+ MyHost = null;
+ }
+
+ // Check if this is a hot reload (flag for post-init handling)
+ var isCharacterLoaded = CoreManager.Current.CharacterFilter.LoginStatus == 3;
+ var needsHotReload = IsHotReload || isCharacterLoaded;
- WriteToChat("Mosswart Massacre has started!");
+ // Clean up old event subscriptions to prevent duplicates on hot reload.
+ // C# -= with a non-subscribed handler is a no-op, so safe on first load.
+ if (_chatEventRouter != null)
+ CoreManager.Current.ChatBoxMessage -= new EventHandler(_chatEventRouter.OnChatText);
+ CoreManager.Current.ChatBoxMessage -= new EventHandler(ChatEventRouter.AllChatText);
+ CoreManager.Current.CommandLineText -= OnChatCommand;
+ CoreManager.Current.CharacterFilter.LoginComplete -= CharacterFilter_LoginComplete;
+ CoreManager.Current.CharacterFilter.Death -= OnCharacterDeath;
+ CoreManager.Current.WorldFilter.CreateObject -= OnSpawn;
+ CoreManager.Current.WorldFilter.CreateObject -= OnPortalDetected;
+ CoreManager.Current.WorldFilter.ReleaseObject -= OnDespawn;
+ if (_inventoryMonitor != null)
+ {
+ CoreManager.Current.WorldFilter.CreateObject -= _inventoryMonitor.OnInventoryCreate;
+ CoreManager.Current.WorldFilter.ReleaseObject -= _inventoryMonitor.OnInventoryRelease;
+ CoreManager.Current.WorldFilter.ChangeObject -= _inventoryMonitor.OnInventoryChange;
+ }
+ if (_liveInventoryTracker != null)
+ {
+ CoreManager.Current.WorldFilter.CreateObject -= _liveInventoryTracker.OnCreateObject;
+ CoreManager.Current.WorldFilter.ReleaseObject -= _liveInventoryTracker.OnReleaseObject;
+ CoreManager.Current.WorldFilter.ChangeObject -= _liveInventoryTracker.OnChangeObject;
+ }
+ if (_gameEventRouter != null)
+ CoreManager.Current.EchoFilter.ServerDispatch -= _gameEventRouter.OnServerDispatch;
+ WebSocket.OnServerCommand -= HandleServerCommand;
- // Subscribe to chat message event
- CoreManager.Current.ChatBoxMessage += new EventHandler(OnChatText);
+ // Stop old timers before recreating (prevents timer leaks on hot reload)
+ _killTracker?.Stop();
+ if (vitalsTimer != null)
+ {
+ vitalsTimer.Stop();
+ vitalsTimer.Dispose();
+ vitalsTimer = null;
+ }
+ if (commandTimer != null)
+ {
+ commandTimer.Stop();
+ commandTimer.Dispose();
+ commandTimer = null;
+ }
+ if (_updateCheckTimer != null)
+ {
+ _updateCheckTimer.Stop();
+ _updateCheckTimer.Dispose();
+ _updateCheckTimer = null;
+ }
+
+ // Initialize kill tracker (owns the 1-sec stats timer)
+ _killTracker = new KillTracker(
+ this,
+ (kills, per5, perHr) => ViewManager.UpdateKillStats(kills, per5, perHr),
+ elapsed => ViewManager.UpdateElapsedTime(elapsed));
+ _staticKillTracker = _killTracker;
+ _killTracker.Start();
+
+ // Initialize inventory monitor (taper tracking)
+ _inventoryMonitor = new InventoryMonitor(this);
+ _staticInventoryMonitor = _inventoryMonitor;
+
+ // Initialize live inventory tracker (delta WebSocket messages)
+ _liveInventoryTracker = new LiveInventoryTracker(this);
+
+ // Initialize chat event router (rareTracker set later in LoginComplete)
+ _chatEventRouter = new ChatEventRouter(
+ this, _killTracker, null,
+ count => ViewManager.UpdateRareCount(count),
+ msg => MyHost?.Actions.InvokeChatParser($"/a {msg}"));
+
+ // Initialize game event router
+ _gameEventRouter = new GameEventRouter(this);
+
+ // Note: Startup messages will appear after character login
+ // Subscribe to events
+ CoreManager.Current.ChatBoxMessage += new EventHandler(_chatEventRouter.OnChatText);
+ CoreManager.Current.ChatBoxMessage += new EventHandler(ChatEventRouter.AllChatText);
CoreManager.Current.CommandLineText += OnChatCommand;
CoreManager.Current.CharacterFilter.LoginComplete += CharacterFilter_LoginComplete;
+ CoreManager.Current.CharacterFilter.Death += OnCharacterDeath;
+ CoreManager.Current.WorldFilter.CreateObject += OnSpawn;
+ CoreManager.Current.WorldFilter.CreateObject += OnPortalDetected;
+ CoreManager.Current.WorldFilter.ReleaseObject += OnDespawn;
+ CoreManager.Current.WorldFilter.CreateObject += _inventoryMonitor.OnInventoryCreate;
+ CoreManager.Current.WorldFilter.ReleaseObject += _inventoryMonitor.OnInventoryRelease;
+ CoreManager.Current.WorldFilter.ChangeObject += _inventoryMonitor.OnInventoryChange;
+ CoreManager.Current.WorldFilter.CreateObject += _liveInventoryTracker.OnCreateObject;
+ CoreManager.Current.WorldFilter.ReleaseObject += _liveInventoryTracker.OnReleaseObject;
+ CoreManager.Current.WorldFilter.ChangeObject += _liveInventoryTracker.OnChangeObject;
- // Initialize the timer
- updateTimer = new Timer(1000); // Update every second
- updateTimer.Elapsed += UpdateStats;
- updateTimer.Start();
+ // Initialize VVS view after character login
+ ViewManager.ViewInit();
- // Initialize the view (UI)
- MainView.ViewInit();
+ // Initialize vitals streaming timer
+ vitalsTimer = new Timer(Constants.VitalsUpdateIntervalMs);
+ vitalsTimer.Elapsed += SendVitalsUpdate;
+ vitalsTimer.Start();
+
+ // Initialize command processing timer (Windows Forms timer for main thread)
+ commandTimer = new System.Windows.Forms.Timer();
+ commandTimer.Interval = Constants.CommandProcessIntervalMs;
+ commandTimer.Tick += ProcessPendingCommands;
+ commandTimer.Start();
+
+ // Note: View initialization moved to LoginComplete for VVS compatibility
+
+ // Initialize character stats and hook ServerDispatch early
+ // 0x0013 (character properties with luminance) fires DURING login,
+ // BEFORE LoginComplete — must hook here to catch it
+ CharacterStats.Init(this);
+ CoreManager.Current.EchoFilter.ServerDispatch += _gameEventRouter.OnServerDispatch;
// Enable TLS1.2
ServicePointManager.SecurityProtocol |= SecurityProtocolType.Tls12;
//Enable vTank interface
vTank.Enable();
+ // Set logger for WebSocket
+ WebSocket.SetLogger(this);
+ WebSocket.SetGameStats(this);
+ //lyssna på commands
+ WebSocket.OnServerCommand += HandleServerCommand;
+ //starta inventory. Hanterar subscriptions i den med
+
+ _inventoryLogger = new MossyInventory();
+
+ // Initialize navigation visualization system
+ navVisualization = new NavVisualization();
+
+ // Initialize command router
+ _commandRouter = new CommandRouter();
+ RegisterCommands();
+
+ // Note: ChestLooter is initialized in LoginComplete after PluginSettings.Initialize()
+
+ // Note: DECAL Harmony patches will be initialized in LoginComplete event
+ // where the chat system is available for error messages
+
+ // Hot reload: run after all core objects are initialized
+ if (needsHotReload && isCharacterLoaded)
+ {
+ try
+ {
+ WriteToChat("[INFO] Hot reload detected - reinitializing plugin");
+ InitializeForHotReload();
+ WriteToChat("[INFO] Hot reload initialization complete");
+ }
+ catch (Exception ex)
+ {
+ WriteToChat($"[ERROR] Hot reload initialization failed: {ex.Message}");
+ }
+ }
+
+ // Auto-update: check for updates 30s after startup
+ _updateCheckTimer = new Timer(30000);
+ _updateCheckTimer.AutoReset = false;
+ _updateCheckTimer.Elapsed += (s, ev) =>
+ {
+ Task.Run(() => UpdateManager.CheckAndInstallAsync());
+ _updateCheckTimer?.Dispose();
+ _updateCheckTimer = null;
+ };
+ _updateCheckTimer.Start();
+
}
catch (Exception ex)
{
@@ -66,26 +339,104 @@ namespace MosswartMassacre
try
{
PluginSettings.Save();
- if (TelemetryEnabled)
- Telemetry.Stop(); // ensure no dangling timer / HttpClient
- WriteToChat("Mosswart Massacre is shutting down...");
+ WriteToChat("Mosswart Massacre is shutting down. Bye!");
+
+
// Unsubscribe from chat message event
- CoreManager.Current.ChatBoxMessage -= new EventHandler(OnChatText);
+ CoreManager.Current.ChatBoxMessage -= new EventHandler(_chatEventRouter.OnChatText);
CoreManager.Current.CommandLineText -= OnChatCommand;
-
- // Stop and dispose of the timer
- if (updateTimer != null)
+ CoreManager.Current.ChatBoxMessage -= new EventHandler(ChatEventRouter.AllChatText);
+ CoreManager.Current.CharacterFilter.LoginComplete -= CharacterFilter_LoginComplete;
+ CoreManager.Current.CharacterFilter.Death -= OnCharacterDeath;
+ CoreManager.Current.WorldFilter.CreateObject -= OnSpawn;
+ CoreManager.Current.WorldFilter.CreateObject -= OnPortalDetected;
+ CoreManager.Current.WorldFilter.ReleaseObject -= OnDespawn;
+ // Unsubscribe inventory monitor
+ if (_inventoryMonitor != null)
{
- updateTimer.Stop();
- updateTimer.Dispose();
- updateTimer = null;
+ CoreManager.Current.WorldFilter.CreateObject -= _inventoryMonitor.OnInventoryCreate;
+ CoreManager.Current.WorldFilter.ReleaseObject -= _inventoryMonitor.OnInventoryRelease;
+ CoreManager.Current.WorldFilter.ChangeObject -= _inventoryMonitor.OnInventoryChange;
+ }
+ // Unsubscribe from server dispatch
+ CoreManager.Current.EchoFilter.ServerDispatch -= _gameEventRouter.OnServerDispatch;
+
+ // Stop kill tracker
+ _killTracker?.Stop();
+
+ if (vitalsTimer != null)
+ {
+ vitalsTimer.Stop();
+ vitalsTimer.Dispose();
+ vitalsTimer = null;
+ }
+
+ if (commandTimer != null)
+ {
+ commandTimer.Stop();
+ commandTimer.Dispose();
+ commandTimer = null;
+ }
+
+ // Stop quest streaming service
+ _questStreamingService?.Stop();
+ _questStreamingService = null;
+
+ // Stop and dispose character stats timer
+ if (characterStatsTimer != null)
+ {
+ characterStatsTimer.Stop();
+ characterStatsTimer.Elapsed -= OnCharacterStatsUpdate;
+ characterStatsTimer.Dispose();
+ characterStatsTimer = null;
+ }
+
+ if (_updateCheckTimer != null)
+ {
+ _updateCheckTimer.Stop();
+ _updateCheckTimer.Dispose();
+ _updateCheckTimer = null;
+ }
+
+ // Dispose quest manager
+ if (questManager != null)
+ {
+ questManager.Dispose();
+ questManager = null;
}
// Clean up the view
- MainView.ViewDestroy();
+ ViewManager.ViewDestroy();
//Disable vtank interface
vTank.Disable();
+ // sluta lyssna på commands
+ WebSocket.OnServerCommand -= HandleServerCommand;
+ WebSocket.Stop();
+ //shutdown inv
+ _inventoryLogger.Dispose();
+
+ // Clean up navigation visualization
+ if (navVisualization != null)
+ {
+ navVisualization.Dispose();
+ navVisualization = null;
+ }
+
+ // Clean up taper tracking
+ _inventoryMonitor?.Cleanup();
+
+ // Clean up live inventory tracker
+ if (_liveInventoryTracker != null)
+ {
+ CoreManager.Current.WorldFilter.CreateObject -= _liveInventoryTracker.OnCreateObject;
+ CoreManager.Current.WorldFilter.ReleaseObject -= _liveInventoryTracker.OnReleaseObject;
+ CoreManager.Current.WorldFilter.ChangeObject -= _liveInventoryTracker.OnChangeObject;
+ _liveInventoryTracker.Cleanup();
+ }
+
+ // Clean up Harmony patches
+ DecalHarmonyClean.Cleanup();
MyHost = null;
}
@@ -98,117 +449,433 @@ namespace MosswartMassacre
{
CoreManager.Current.CharacterFilter.LoginComplete -= CharacterFilter_LoginComplete;
+ WriteToChat("Mosswart Massacre has started!");
+
+
+
PluginSettings.Initialize(); // Safe to call now
- // Apply the values
- RareMetaEnabled = PluginSettings.Instance.RareMetaEnabled;
- RemoteCommandsEnabled = PluginSettings.Instance.RemoteCommandsEnabled;
- HttpServerEnabled = PluginSettings.Instance.HttpServerEnabled;
- TelemetryEnabled = PluginSettings.Instance.TelemetryEnabled;
- CharTag = PluginSettings.Instance.CharTag;
- MainView.SetRareMetaToggleState(RareMetaEnabled);
- if (TelemetryEnabled)
- Telemetry.Start();
-
-
-
- }
-
- private void OnChatText(object sender, ChatTextInterceptEventArgs e)
- {
+ // Initialize chest looter system (needs PluginSettings to be ready)
try
{
- // WriteToChat($"[Debug] Chat Color: {e.Color}, Message: {e.Text}");
-
- if (IsKilledByMeMessage(e.Text))
+ chestLooter = new ChestLooter(CoreManager.Current, PluginSettings.Instance.ChestLooterSettings);
+ chestLooter.Initialize();
+ chestLooter.StatusChanged += (sender, status) =>
{
- totalKills++;
- lastKillTime = DateTime.Now;
- CalculateKillsPerInterval();
- MainView.UpdateKillStats(totalKills, killsPer5Min, killsPerHour);
- }
-
- if (IsRareDiscoveryMessage(e.Text, out string rareText))
- {
- rareCount++;
- MainView.UpdateRareCount(rareCount);
-
- if (RareMetaEnabled)
- {
- Decal_DispatchOnChatCommand("/vt setmetastate loot_rare");
- }
-
- DelayedCommandManager.AddDelayedCommand($"/a {rareText}", 3000);
- }
- // if (e.Text.EndsWith("!testrare\""))
- // {
- // string simulatedText = $"{CoreManager.Current.CharacterFilter.Name} has discovered the Ancient Pickle!";
- //
- // if (IsRareDiscoveryMessage(simulatedText, out string simulatedRareText))
- // {
- // rareCount++;
- // MainView.UpdateRareCount(rareCount);
- //
- // if (RareMetaEnabled)
- // {
- // Decal_DispatchOnChatCommand("/vt setmetastate loot_rare");
- // }
- //
- // DelayedCommandManager.AddDelayedCommand($"/a {simulatedRareText}", 3000);
- // }
- // else
- // {
- // WriteToChat("[Test] Simulated rare message didn't match the regex.");
- // }
- //
- // return;
- // }
- if (e.Color == 18 && e.Text.EndsWith("!report\""))
- {
- TimeSpan elapsed = DateTime.Now - statsStartTime;
- string reportMessage = $"Total Kills: {totalKills}, Kills per Hour: {killsPerHour:F2}, Elapsed Time: {elapsed:dd\\.hh\\:mm\\:ss}, Rares Found: {rareCount}";
- WriteToChat($"[Mosswart Massacre] Reporting to allegiance: {reportMessage}");
- MyHost.Actions.InvokeChatParser($"/a {reportMessage}");
- }
- if (RemoteCommandsEnabled && e.Color == 18)
- {
- string characterName = Regex.Escape(CoreManager.Current.CharacterFilter.Name);
- string pattern = $@"^\[Allegiance\].*Dunking Rares.*say[s]?, \""!do {characterName} (?.+)\""$";
- string tag = Regex.Escape(PluginCore.CharTag);
- string patterntag = $@"^\[Allegiance\].*Dunking Rares.*say[s]?, \""!dot {tag} (?.+)\""$";
-
-
- var match = Regex.Match(e.Text, pattern);
- var matchtag = Regex.Match(e.Text, patterntag);
-
- if (match.Success)
- {
- string command = match.Groups["command"].Value;
- DispatchChatToBoxWithPluginIntercept(command);
- DelayedCommandManager.AddDelayedCommand($"/a [Remote] Executing: {command}", 2000);
- }
- else if (matchtag.Success)
- {
- string command = matchtag.Groups["command"].Value;
- DispatchChatToBoxWithPluginIntercept(command);
- DelayedCommandManager.AddDelayedCommand($"/a [Remote] Executing: {command}", 2000);
- }
-
- }
-
-
-
-
-
-
-
-
+ VVSTabbedMainView.UpdateChestLooterStatus(status);
+ };
}
catch (Exception ex)
{
- WriteToChat("Error processing chat message: " + ex.Message);
+ WriteToChat($"[ChestLooter] Initialization failed: {ex.Message}");
+ }
+
+ // Initialize rare tracker and wire to chat router
+ _rareTracker = new RareTracker(this);
+ _staticRareTracker = _rareTracker;
+ _chatEventRouter.SetRareTracker(_rareTracker);
+
+ // Apply the values
+ _rareTracker.RareMetaEnabled = PluginSettings.Instance.RareMetaEnabled;
+ WebSocketEnabled = PluginSettings.Instance.WebSocketEnabled;
+ CharTag = PluginSettings.Instance.CharTag;
+ ViewManager.SetRareMetaToggleState(RareMetaEnabled);
+ ViewManager.RefreshSettingsFromConfig(); // Refresh all UI settings after loading
+ if (WebSocketEnabled)
+ WebSocket.Start();
+
+ // Initialize Harmony patches using UtilityBelt's loaded DLL
+ try
+ {
+ bool success = DecalHarmonyClean.Initialize();
+ if (success)
+ WriteToChat("[OK] Plugin message interception active");
+ else
+ WriteToChat("[FAIL] Could not initialize message interception");
+ }
+ catch (Exception ex)
+ {
+ WriteToChat($"[ERROR] Harmony initialization failed: {ex.Message}");
+ }
+
+ // Initialize death tracking
+ _killTracker.SetTotalDeaths(CoreManager.Current.CharacterFilter.GetCharProperty((int)IntValueKey.NumDeaths));
+
+ // Initialize cached Prismatic Taper count
+ _inventoryMonitor.Initialize();
+
+ // Initialize live inventory tracking (after full inventory dump)
+ _liveInventoryTracker?.Initialize();
+
+ // Initialize quest manager for always-on quest streaming
+ try
+ {
+ questManager = new QuestManager();
+
+ // Trigger full quest data refresh (same as clicking refresh button)
+ Views.FlagTrackerView.RefreshQuestData();
+
+ // Initialize quest streaming service (30 seconds)
+ _questStreamingService = new QuestStreamingService(this);
+ _questStreamingService.Start();
+
+ WriteToChat("[OK] Quest streaming initialized with full data refresh");
+ }
+ catch (Exception ex)
+ {
+ WriteToChat($"[ERROR] Quest streaming initialization failed: {ex.Message}");
+ }
+
+ // Start character stats streaming
+ // Note: Init() and ServerDispatch hook are in Startup() so we catch
+ // 0x0013 (luminance/properties) which fires BEFORE LoginComplete
+ try
+ {
+ // Start 10-minute character stats timer
+ characterStatsTimer = new Timer(Constants.CharacterStatsIntervalMs);
+ characterStatsTimer.Elapsed += OnCharacterStatsUpdate;
+ characterStatsTimer.AutoReset = true;
+ characterStatsTimer.Start();
+
+ // Send initial stats after 5-second delay (let CharacterFilter populate)
+ var initialDelay = new Timer(Constants.LoginDelayMs);
+ initialDelay.AutoReset = false;
+ initialDelay.Elapsed += (s, args) =>
+ {
+ CharacterStats.CollectAndSend();
+ ((Timer)s).Dispose();
+ };
+ initialDelay.Start();
+
+ WriteToChat("[OK] Character stats streaming initialized (10-min interval)");
+ }
+ catch (Exception ex)
+ {
+ WriteToChat($"[ERROR] Character stats initialization failed: {ex.Message}");
+ }
+
+ }
+
+
+ private void InitializeForHotReload()
+ {
+ // This method handles initialization that depends on character being logged in
+ // Similar to LoginComplete but designed for hot reload scenarios
+
+ WriteToChat("Mosswart Massacre hot reload initialization started!");
+
+ // 1. Initialize settings - CRITICAL first step
+ PluginSettings.Initialize();
+
+ // 1b. Initialize chest looter system (needs PluginSettings to be ready)
+ try
+ {
+ chestLooter = new ChestLooter(CoreManager.Current, PluginSettings.Instance.ChestLooterSettings);
+ chestLooter.Initialize();
+ chestLooter.StatusChanged += (sender, status) =>
+ {
+ VVSTabbedMainView.UpdateChestLooterStatus(status);
+ };
+ }
+ catch (Exception ex)
+ {
+ WriteToChat($"[ChestLooter] Initialization failed: {ex.Message}");
+ }
+
+ // 2. Initialize rare tracker if not already set (missed when LoginComplete doesn't fire)
+ if (_rareTracker == null)
+ {
+ _rareTracker = new RareTracker(this);
+ _staticRareTracker = _rareTracker;
+ _chatEventRouter.SetRareTracker(_rareTracker);
+ }
+
+ // Apply the values from settings
+ _rareTracker.RareMetaEnabled = PluginSettings.Instance.RareMetaEnabled;
+ WebSocketEnabled = PluginSettings.Instance.WebSocketEnabled;
+ CharTag = PluginSettings.Instance.CharTag;
+
+ // 3. Update UI with current settings and restore window position
+ ViewManager.SetRareMetaToggleState(RareMetaEnabled);
+ ViewManager.RefreshSettingsFromConfig();
+ ViewManager.RestoreWindowPosition();
+
+ // 4. Restart services if they were enabled (stop first, then start)
+ if (WebSocketEnabled)
+ {
+ WebSocket.Stop(); // Stop existing
+ WebSocket.Start(); // Restart
+ }
+
+ // 5. Initialize Harmony patches (only if not already done)
+ // Note: Harmony patches are global and don't need reinitialization
+ if (!DecalHarmonyClean.IsActive())
+ {
+ try
+ {
+ bool success = DecalHarmonyClean.Initialize();
+ if (success)
+ WriteToChat("[OK] Plugin message interception active");
+ else
+ WriteToChat("[FAIL] Could not initialize message interception");
+ }
+ catch (Exception ex)
+ {
+ WriteToChat($"[ERROR] Harmony initialization failed: {ex.Message}");
+ }
+ }
+
+ // 6. Reinitialize death tracking
+ _killTracker?.SetTotalDeaths(CoreManager.Current.CharacterFilter.GetCharProperty((int)IntValueKey.NumDeaths));
+
+ // 7. Reinitialize cached Prismatic Taper count
+ _inventoryMonitor?.Initialize();
+
+ // 7b. Reinitialize live inventory tracking
+ _liveInventoryTracker?.Initialize();
+
+ // 8. Reinitialize quest manager for hot reload
+ try
+ {
+ if (questManager == null)
+ {
+ questManager = new QuestManager();
+ WriteToChat("[OK] Quest manager reinitialized");
+ }
+ else
+ {
+ WriteToChat("[INFO] Quest manager already active");
+ }
+
+ // Trigger full quest data refresh (same as clicking refresh button)
+ Views.FlagTrackerView.RefreshQuestData();
+ WriteToChat("[INFO] Quest data refresh triggered for hot reload");
+ }
+ catch (Exception ex)
+ {
+ WriteToChat($"[ERROR] Quest manager hot reload failed: {ex.Message}");
+ }
+
+ // 9. Reinitialize quest streaming service for hot reload
+ try
+ {
+ _questStreamingService?.Stop();
+ _questStreamingService = new QuestStreamingService(this);
+ _questStreamingService.Start();
+
+ WriteToChat("[OK] Quest streaming service reinitialized (30s interval)");
+ }
+ catch (Exception ex)
+ {
+ WriteToChat($"[ERROR] Quest streaming service hot reload failed: {ex.Message}");
+ }
+
+ // 10. Reinitialize character stats streaming
+ try
+ {
+ if (characterStatsTimer == null)
+ {
+ characterStatsTimer = new Timer(Constants.CharacterStatsIntervalMs);
+ characterStatsTimer.Elapsed += OnCharacterStatsUpdate;
+ characterStatsTimer.AutoReset = true;
+ characterStatsTimer.Start();
+ }
+
+ // Send initial stats after short delay
+ var initialDelay = new Timer(Constants.LoginDelayMs);
+ initialDelay.AutoReset = false;
+ initialDelay.Elapsed += (s, args) =>
+ {
+ CharacterStats.CollectAndSend();
+ ((Timer)s).Dispose();
+ };
+ initialDelay.Start();
+
+ WriteToChat("[OK] Character stats streaming initialized");
+ }
+ catch (Exception ex)
+ {
+ WriteToChat($"[ERROR] Character stats hot reload failed: {ex.Message}");
+ }
+
+ WriteToChat("Hot reload initialization completed!");
+ }
+
+
+ private async void OnSpawn(object sender, CreateObjectEventArgs e)
+ {
+ var mob = e.New;
+ if (mob.ObjectClass != ObjectClass.Monster) return;
+
+ try
+ {
+ // Get DECAL coordinates
+ var decalCoords = mob.Coordinates();
+ if (decalCoords == null) return;
+
+ const string fmt = "F7";
+ string ns = decalCoords.NorthSouth.ToString(fmt, CultureInfo.InvariantCulture);
+ string ew = decalCoords.EastWest.ToString(fmt, CultureInfo.InvariantCulture);
+
+ // Get Z coordinate using RawCoordinates() for accurate world Z position
+ string zCoord = "0";
+ try
+ {
+ var rawCoords = mob.RawCoordinates();
+ if (rawCoords != null)
+ {
+ zCoord = rawCoords.Z.ToString("F2", CultureInfo.InvariantCulture);
+ }
+ else
+ {
+ // Fallback to player Z approximation if RawCoordinates fails
+ var playerCoords = Coordinates.Me;
+ if (Math.Abs(playerCoords.Z) > 0.1)
+ {
+ zCoord = playerCoords.Z.ToString("F2", CultureInfo.InvariantCulture);
+ }
+ }
+ }
+ catch
+ {
+ // Fallback to player Z approximation on error
+ try
+ {
+ var playerCoords = Coordinates.Me;
+ if (Math.Abs(playerCoords.Z) > 0.1)
+ {
+ zCoord = playerCoords.Z.ToString("F2", CultureInfo.InvariantCulture);
+ }
+ }
+ catch
+ {
+ zCoord = "0";
+ }
+ }
+
+ await WebSocket.SendSpawnAsync(ns, ew, zCoord, mob.Name);
+ }
+ catch (Exception ex)
+ {
+ PluginCore.WriteToChat($"[WS] Spawn send failed: {ex}");
}
}
+
+ private async void OnPortalDetected(object sender, CreateObjectEventArgs e)
+ {
+ var portal = e.New;
+ if (portal.ObjectClass != ObjectClass.Portal) return;
+
+ try
+ {
+ // Get portal coordinates from DECAL
+ var decalCoords = portal.Coordinates();
+ if (decalCoords == null) return;
+
+ const string fmt = "F7";
+ string ns = decalCoords.NorthSouth.ToString(fmt, CultureInfo.InvariantCulture);
+ string ew = decalCoords.EastWest.ToString(fmt, CultureInfo.InvariantCulture);
+
+ // Get Z coordinate using RawCoordinates() for accurate world Z position
+ string zCoord = "0";
+ try
+ {
+ var rawCoords = portal.RawCoordinates();
+ if (rawCoords != null)
+ {
+ zCoord = rawCoords.Z.ToString("F2", CultureInfo.InvariantCulture);
+ }
+ else
+ {
+ // Fallback to player Z approximation if RawCoordinates fails
+ var playerCoords = Coordinates.Me;
+ if (Math.Abs(playerCoords.Z) > 0.1)
+ {
+ zCoord = playerCoords.Z.ToString("F2", CultureInfo.InvariantCulture);
+ }
+ }
+ }
+ catch
+ {
+ // Fallback to player Z approximation on error
+ try
+ {
+ var playerCoords = Coordinates.Me;
+ if (Math.Abs(playerCoords.Z) > 0.1)
+ {
+ zCoord = playerCoords.Z.ToString("F2", CultureInfo.InvariantCulture);
+ }
+ }
+ catch
+ {
+ zCoord = "0";
+ }
+ }
+
+ await WebSocket.SendPortalAsync(ns, ew, zCoord, portal.Name);
+ }
+ catch (Exception ex)
+ {
+ PluginCore.WriteToChat($"[PORTAL ERROR] {ex.Message}");
+ PluginCore.WriteToChat($"[WS] Portal send failed: {ex}");
+ }
+ }
+
+
+ private void OnDespawn(object sender, ReleaseObjectEventArgs e)
+ {
+ var mob = e.Released;
+ if (mob.ObjectClass != ObjectClass.Monster) return;
+
+
+ // var c = mob.Coordinates();
+ // PluginCore.WriteToChat(
+ // $"[Despawn] {mob.Name} @ (NS={c.NorthSouth:F1}, EW={c.EastWest:F1})");
+ }
+
+
+ private void OnCharacterDeath(object sender, Decal.Adapter.Wrappers.DeathEventArgs e)
+ {
+ _killTracker.OnDeath();
+ _killTracker.SetTotalDeaths(CoreManager.Current.CharacterFilter.GetCharProperty((int)IntValueKey.NumDeaths));
+ }
+
+ private void HandleServerCommand(CommandEnvelope env)
+ {
+ // This is called from WebSocket thread - queue for main thread execution
+ lock (pendingCommands)
+ {
+ pendingCommands.Enqueue(env.Command);
+ }
+ }
+
+ private void ProcessPendingCommands(object sender, EventArgs e)
+ {
+ // This runs on the main UI thread via Windows Forms timer
+ string command = null;
+
+ lock (pendingCommands)
+ {
+ if (pendingCommands.Count > 0)
+ command = pendingCommands.Dequeue();
+ }
+
+ if (command != null)
+ {
+ try
+ {
+ // Execute ALL WebSocket commands on main thread - fast and reliable
+ DispatchChatToBoxWithPluginIntercept(command);
+ }
+ catch (Exception ex)
+ {
+ WriteToChat($"[WS] Command execution error: {ex.Message}");
+ }
+ }
+ }
+
+
+
private void OnChatCommand(object sender, ChatParserInterceptEventArgs e)
{
try
@@ -225,122 +892,108 @@ namespace MosswartMassacre
}
}
- private void UpdateStats(object sender, ElapsedEventArgs e)
+
+ private static void SendVitalsUpdate(object sender, ElapsedEventArgs e)
{
try
{
- // Update the elapsed time
- TimeSpan elapsed = DateTime.Now - statsStartTime;
- MainView.UpdateElapsedTime(elapsed);
+ // Only send if WebSocket is enabled
+ if (!WebSocketEnabled)
+ return;
- // Recalculate kill rates
- CalculateKillsPerInterval();
- MainView.UpdateKillStats(totalKills, killsPer5Min, killsPerHour);
+ // Collect vitals data
+ int currentHealth = CoreManager.Current.Actions.Vital[VitalType.CurrentHealth];
+ int currentStamina = CoreManager.Current.Actions.Vital[VitalType.CurrentStamina];
+ int currentMana = CoreManager.Current.Actions.Vital[VitalType.CurrentMana];
+
+ int maxHealth = CoreManager.Current.Actions.Vital[VitalType.MaximumHealth];
+ int maxStamina = CoreManager.Current.Actions.Vital[VitalType.MaximumStamina];
+ int maxMana = CoreManager.Current.Actions.Vital[VitalType.MaximumMana];
+
+ int vitae = CoreManager.Current.CharacterFilter.Vitae;
+
+ // Create vitals data structure
+ var vitalsData = new
+ {
+ type = "vitals",
+ timestamp = DateTime.UtcNow.ToString("o"),
+ character_name = CoreManager.Current.CharacterFilter.Name,
+ health_current = currentHealth,
+ health_max = maxHealth,
+ health_percentage = maxHealth > 0 ? Math.Round((double)currentHealth / maxHealth * 100, 1) : 0,
+ stamina_current = currentStamina,
+ stamina_max = maxStamina,
+ stamina_percentage = maxStamina > 0 ? Math.Round((double)currentStamina / maxStamina * 100, 1) : 0,
+ mana_current = currentMana,
+ mana_max = maxMana,
+ mana_percentage = maxMana > 0 ? Math.Round((double)currentMana / maxMana * 100, 1) : 0,
+ vitae = vitae
+ };
+
+ // Send via WebSocket
+ _ = WebSocket.SendVitalsAsync(vitalsData);
}
catch (Exception ex)
{
- WriteToChat("Error updating stats: " + ex.Message);
+ WriteToChat($"Error sending vitals: {ex.Message}");
}
}
- private void CalculateKillsPerInterval()
+ private static void OnCharacterStatsUpdate(object sender, ElapsedEventArgs e)
{
- double minutesElapsed = (DateTime.Now - statsStartTime).TotalMinutes;
-
- if (minutesElapsed > 0)
+ try
{
- killsPer5Min = (totalKills / minutesElapsed) * 5;
- killsPerHour = (totalKills / minutesElapsed) * 60;
+ CharacterStats.CollectAndSend();
+ }
+ catch (Exception ex)
+ {
+ WriteToChat($"[CharStats] Timer error: {ex.Message}");
}
}
- private bool IsKilledByMeMessage(string text)
- {
- string[] killPatterns = new string[]
- {
- @"^You flatten (?.+)'s body with the force of your assault!$",
- @"^You bring (?.+) to a fiery end!$",
- @"^You beat (?.+) to a lifeless pulp!$",
- @"^You smite (?.+) mightily!$",
- @"^You obliterate (?.+)!$",
- @"^You run (?.+) through!$",
- @"^You reduce (?.+) to a sizzling, oozing mass!$",
- @"^You knock (?.+) into next Morningthaw!$",
- @"^You split (?.+) apart!$",
- @"^You cleave (?.+) in twain!$",
- @"^You slay (?.+) viciously enough to impart death several times over!$",
- @"^You reduce (?.+) to a drained, twisted corpse!$",
- @"^Your killing blow nearly turns (?.+) inside-out!$",
- @"^Your attack stops (?.+) cold!$",
- @"^Your lightning coruscates over (?.+)'s mortal remains!$",
- @"^Your assault sends (?.+) to an icy death!$",
- @"^You killed (?.+)!$",
- @"^The thunder of crushing (?.+) is followed by the deafening silence of death!$",
- @"^The deadly force of your attack is so strong that (?.+)'s ancestors feel it!$",
- @"^(?.+)'s seared corpse smolders before you!$",
- @"^(?.+) is reduced to cinders!$",
- @"^(?.+) is shattered by your assault!$",
- @"^(?.+) catches your attack, with dire consequences!$",
- @"^(?.+) is utterly destroyed by your attack!$",
- @"^(?.+) suffers a frozen fate!$",
- @"^(?.+)'s perforated corpse falls before you!$",
- @"^(?.+) is fatally punctured!$",
- @"^(?.+)'s death is preceded by a sharp, stabbing pain!$",
- @"^(?.+) is torn to ribbons by your assault!$",
- @"^(?.+) is liquified by your attack!$",
- @"^(?.+)'s last strength dissolves before you!$",
- @"^Electricity tears (?.+) apart!$",
- @"^Blistered by lightning, (?.+) falls!$",
- @"^(?.+)'s last strength withers before you!$",
- @"^(?.+) is dessicated by your attack!$",
- @"^(?.+) is incinerated by your assault!$"
- };
- foreach (string pattern in killPatterns)
- {
- if (Regex.IsMatch(text, pattern))
- return true;
- }
-
- return false;
- }
- private bool IsRareDiscoveryMessage(string text, out string rareTextOnly)
- {
- rareTextOnly = null;
-
- // Match pattern: " has discovered the !"
- string pattern = @"^(?['A-Za-z ]+)\s(?has discovered the .*!$)";
- Match match = Regex.Match(text, pattern);
-
- if (match.Success && match.Groups["name"].Value == CoreManager.Current.CharacterFilter.Name)
- {
- rareTextOnly = match.Groups["text"].Value; // just "has discovered the Ancient Pickle!"
- return true;
- }
-
- return false;
- }
public static void WriteToChat(string message)
{
- MyHost.Actions.AddChatText("[Mosswart Massacre] " + message, 0, 1);
+ try
+ {
+ // For hot reload scenarios where MyHost might be null, use CoreManager directly
+ if (MyHost != null)
+ {
+ MyHost.Actions.AddChatText("[Mosswart Massacre] " + message, 0, 1);
+ }
+ else
+ {
+ // Hot reload fallback1 - use CoreManager directly like the original template
+ CoreManager.Current.Actions.AddChatText("[Mosswart Massacre] " + message, 1);
+ }
+ }
+ catch (Exception ex)
+ {
+ // Last resort fallback - try CoreManager even if MyHost was supposed to work
+ try
+ {
+ CoreManager.Current.Actions.AddChatText($"[Mosswart Massacre] {message} (WriteToChat error: {ex.Message})", 1);
+ }
+ catch
+ {
+ // Give up - can't write to chat at all
+ }
+ }
}
+
+ void IPluginLogger.Log(string message) => WriteToChat(message);
+
public static void RestartStats()
{
- totalKills = 0;
- rareCount = 0;
- statsStartTime = DateTime.Now;
- killsPer5Min = 0;
- killsPerHour = 0;
-
- WriteToChat("Stats have been reset.");
- MainView.UpdateKillStats(totalKills, killsPer5Min, killsPerHour);
- MainView.UpdateRareCount(rareCount);
+ _staticKillTracker?.RestartStats();
+ if (_staticRareTracker != null)
+ _staticRareTracker.RareCount = 0;
+ ViewManager.UpdateRareCount(0);
}
public static void ToggleRareMeta()
{
- PluginSettings.Instance.RareMetaEnabled = !PluginSettings.Instance.RareMetaEnabled;
- RareMetaEnabled = PluginSettings.Instance.RareMetaEnabled;
- MainView.SetRareMetaToggleState(RareMetaEnabled);
+ _staticRareTracker?.ToggleRareMeta();
+ ViewManager.SetRareMetaToggleState(RareMetaEnabled);
}
[DllImport("Decal.dll")]
@@ -367,138 +1020,512 @@ namespace MosswartMassacre
}
private void HandleMmCommand(string text)
{
- // Remove the /mm prefix and trim extra whitespace
- string[] args = text.Substring(3).Trim().Split(' ');
+ _commandRouter.Dispatch(text);
+ }
- if (args.Length == 0 || string.IsNullOrEmpty(args[0]))
+ private void RegisterCommands()
+ {
+ _commandRouter.Register("ws", args =>
{
- WriteToChat("Usage: /mm . Try /mm help");
- return;
- }
+ if (args.Length > 1)
+ {
+ if (args[1].Equals("enable", StringComparison.OrdinalIgnoreCase))
+ {
+ WebSocketEnabled = true;
+ WebSocket.Start();
+ PluginSettings.Instance.WebSocketEnabled = true;
+ WriteToChat("WS streaming ENABLED.");
+ }
+ else if (args[1].Equals("disable", StringComparison.OrdinalIgnoreCase))
+ {
+ WebSocketEnabled = false;
+ WebSocket.Stop();
+ PluginSettings.Instance.WebSocketEnabled = false;
+ WriteToChat("WS streaming DISABLED.");
+ }
+ else
+ {
+ WriteToChat("Usage: /mm ws ");
+ }
+ }
+ else
+ {
+ WriteToChat("Usage: /mm ws ");
+ }
+ }, "Enable/disable WebSocket streaming");
- string subCommand = args[0].ToLower();
-
- switch (subCommand)
+ _commandRouter.Register("report", args =>
{
- case "telemetry":
- if (args.Length > 1)
+ TimeSpan elapsed = DateTime.Now - _killTracker.StatsStartTime;
+ string reportMessage = $"Total Kills: {_killTracker.TotalKills}, Kills per Hour: {_killTracker.KillsPerHour:F2}, Elapsed Time: {elapsed:dd\\.hh\\:mm\\:ss}, Rares Found: {_rareTracker?.RareCount ?? 0}, Session Deaths: {_killTracker.SessionDeaths}, Total Deaths: {_killTracker.TotalDeaths}";
+ WriteToChat(reportMessage);
+ }, "Show kill/death/rare stats");
+
+ _commandRouter.Register("getmetastate", args =>
+ {
+ string metaState = VtankControl.VtGetMetaState();
+ WriteToChat(metaState);
+ }, "Show current VTank meta state");
+
+ _commandRouter.Register("loc", args =>
+ {
+ Coordinates here = Coordinates.Me;
+ var pos = Utils.GetPlayerPosition();
+ WriteToChat($"Location: {here} (X={pos.X:F1}, Y={pos.Y:F1}, Z={pos.Z:F1})");
+ }, "Show current location");
+
+ _commandRouter.Register("reset", args =>
+ {
+ RestartStats();
+ }, "Reset kill/rare counters");
+
+ _commandRouter.Register("meta", args =>
+ {
+ RareMetaEnabled = !RareMetaEnabled;
+ WriteToChat($"Rare meta state is now {(RareMetaEnabled ? "ON" : "OFF")}");
+ ViewManager.SetRareMetaToggleState(RareMetaEnabled);
+ }, "Toggle rare meta state");
+
+ _commandRouter.Register("nextwp", args =>
+ {
+ double result = VtankControl.VtAdvanceWaypoint();
+ if (result == 1)
+ WriteToChat("Advanced VTank to next waypoint.");
+ else
+ WriteToChat("Failed to advance VTank waypoint. Is VTank running?");
+ }, "Advance VTank to next waypoint");
+
+ _commandRouter.Register("setchest", args =>
+ {
+ if (args.Length < 2)
+ {
+ WriteToChat("[ChestLooter] Usage: /mm setchest ");
+ return;
+ }
+ string chestName = string.Join(" ", args.Skip(1));
+ if (chestLooter != null)
+ {
+ chestLooter.SetChestName(chestName);
+ if (PluginSettings.Instance?.ChestLooterSettings != null)
{
- if (args[1].Equals("enable", StringComparison.OrdinalIgnoreCase))
- {
- TelemetryEnabled = true;
- Telemetry.Start();
- PluginSettings.Instance.TelemetryEnabled = true;
- WriteToChat("Telemetry streaming ENABLED.");
- }
- else if (args[1].Equals("disable", StringComparison.OrdinalIgnoreCase))
- {
- TelemetryEnabled = false;
- Telemetry.Stop();
- PluginSettings.Instance.TelemetryEnabled = false;
- WriteToChat("Telemetry streaming DISABLED.");
- }
- else
- {
- WriteToChat("Usage: /mm telemetry ");
- }
+ PluginSettings.Instance.ChestLooterSettings.ChestName = chestName;
+ PluginSettings.Save();
+ }
+ Views.VVSTabbedMainView.RefreshChestLooterUI();
+ }
+ }, "Set chest name for looter");
+
+ _commandRouter.Register("setkey", args =>
+ {
+ if (args.Length < 2)
+ {
+ WriteToChat("[ChestLooter] Usage: /mm setkey ");
+ return;
+ }
+ string keyName = string.Join(" ", args.Skip(1));
+ if (chestLooter != null)
+ {
+ chestLooter.SetKeyName(keyName);
+ if (PluginSettings.Instance?.ChestLooterSettings != null)
+ {
+ PluginSettings.Instance.ChestLooterSettings.KeyName = keyName;
+ PluginSettings.Save();
+ }
+ Views.VVSTabbedMainView.RefreshChestLooterUI();
+ }
+ }, "Set key name for looter");
+
+ _commandRouter.Register("lootchest", args =>
+ {
+ if (chestLooter != null)
+ {
+ if (!chestLooter.StartByName())
+ WriteToChat("[ChestLooter] Failed to start. Check chest/key names are set.");
+ }
+ else
+ {
+ WriteToChat("[ChestLooter] Chest looter not initialized");
+ }
+ }, "Start chest looting");
+
+ _commandRouter.Register("stoploot", args =>
+ {
+ if (chestLooter != null)
+ chestLooter.Stop();
+ else
+ WriteToChat("[ChestLooter] Chest looter not initialized");
+ }, "Stop chest looting");
+
+ _commandRouter.Register("vtanktest", args =>
+ {
+ try
+ {
+ WriteToChat("Testing VTank interface...");
+ WriteToChat($"VTank Instance: {(vTank.Instance != null ? "Found" : "NULL")}");
+ WriteToChat($"VTank Type: {vTank.Instance?.GetType()?.Name ?? "NULL"}");
+ WriteToChat($"NavCurrent: {vTank.Instance?.NavCurrent ?? -1}");
+ WriteToChat($"NavNumPoints: {vTank.Instance?.NavNumPoints ?? -1}");
+ WriteToChat($"NavType: {vTank.Instance?.NavType}");
+ WriteToChat($"MacroEnabled: {vTank.Instance?.MacroEnabled}");
+ }
+ catch (Exception ex)
+ {
+ WriteToChat($"VTank test error: {ex.Message}");
+ }
+ }, "");
+
+ _commandRouter.Register("decalstatus", args =>
+ {
+ try
+ {
+ WriteToChat("=== Harmony Patch Status (UtilityBelt Pattern) ===");
+ WriteToChat($"Patches Active: {DecalHarmonyClean.IsActive()}");
+ WriteToChat($"Messages Intercepted: {DecalHarmonyClean.GetMessagesIntercepted()}");
+ WriteToChat($"WebSocket Streaming: {(AggressiveChatStreamingEnabled && WebSocketEnabled ? "ACTIVE" : "INACTIVE")}");
+
+ WriteToChat("=== Harmony Version Status ===");
+ try
+ {
+ var harmonyTest = Harmony.HarmonyInstance.Create("test.version.check");
+ WriteToChat($"[OK] Harmony Available (ID: {harmonyTest.Id})");
+ var harmonyAssembly = typeof(Harmony.HarmonyInstance).Assembly;
+ WriteToChat($"[OK] Harmony Version: {harmonyAssembly.GetName().Version}");
+ WriteToChat($"[OK] Harmony Location: {harmonyAssembly.Location}");
+ }
+ catch (Exception harmonyEx)
+ {
+ WriteToChat($"[FAIL] Harmony Test Failed: {harmonyEx.Message}");
+ }
+ }
+ catch (Exception ex)
+ {
+ WriteToChat($"Status check error: {ex.Message}");
+ }
+ }, "");
+
+ _commandRouter.Register("decaldebug", args =>
+ {
+ if (args.Length > 1)
+ {
+ if (args[1].Equals("enable", StringComparison.OrdinalIgnoreCase))
+ {
+ AggressiveChatStreamingEnabled = true;
+ WriteToChat("[OK] DECAL debug streaming ENABLED - will show captured messages + stream via WebSocket");
+ }
+ else if (args[1].Equals("disable", StringComparison.OrdinalIgnoreCase))
+ {
+ AggressiveChatStreamingEnabled = false;
+ WriteToChat("[FAIL] DECAL debug streaming DISABLED - WebSocket streaming also disabled");
}
else
{
- WriteToChat("Usage: /mm telemetry ");
+ WriteToChat("Usage: /mm decaldebug ");
}
- break;
- case "help":
- WriteToChat("Mosswart Massacre Commands:");
- WriteToChat("/mm report - Show current stats");
- WriteToChat("/mm loc - Show current location");
- WriteToChat("/mm telemetry - Telemetry streaming enable|disable"); // NEW
- WriteToChat("/mm reset - Reset all counters");
- WriteToChat("/mm meta - Toggle rare meta state");
- WriteToChat("/mm http - Local http-command server enable|disable");
- WriteToChat("/mm remotecommand - Listen to allegiance !do/!dot enable|disable");
- WriteToChat("/mm getmetastate - Gets the current metastate");
- break;
+ }
+ else
+ {
+ WriteToChat("Usage: /mm decaldebug ");
+ }
+ }, "");
- case "report":
- TimeSpan elapsed = DateTime.Now - statsStartTime;
- string reportMessage = $"Total Kills: {totalKills}, Kills per Hour: {killsPerHour:F2}, Elapsed Time: {elapsed:dd\\.hh\\:mm\\:ss}, Rares Found: {rareCount}";
- WriteToChat(reportMessage);
- break;
- case "getmetastate":
- string metaState = VtankControl.VtGetMetaState();
- WriteToChat(metaState);
- break;
+ _commandRouter.Register("gui", args =>
+ {
+ try
+ {
+ WriteToChat("Attempting to manually initialize GUI...");
+ ViewManager.ViewDestroy();
+ ViewManager.ViewInit();
+ WriteToChat("GUI initialization attempt completed.");
+ }
+ catch (Exception ex)
+ {
+ WriteToChat($"GUI initialization error: {ex.Message}");
+ }
+ }, "Reinitialize GUI");
- case "loc":
- Coordinates here = Coordinates.Me;
- var pos = Utils.GetPlayerPosition();
- WriteToChat($"Location: {here} (X={pos.X:F1}, Y={pos.Y:F1}, Z={pos.Z:F1})");
- break;
- case "reset":
- RestartStats();
- break;
- case "meta":
- RareMetaEnabled = !RareMetaEnabled;
- WriteToChat($"Rare meta state is now {(RareMetaEnabled ? "ON" : "OFF")}");
- MainView.SetRareMetaToggleState(RareMetaEnabled); // <-- sync the UI
- break;
-
- case "http":
- if (args.Length > 1)
+ _commandRouter.Register("testprismatic", args =>
+ {
+ try
+ {
+ WriteToChat("=== FULL INVENTORY DUMP ===");
+ var worldFilter = CoreManager.Current.WorldFilter;
+ var playerInv = CoreManager.Current.CharacterFilter.Id;
+
+ WriteToChat("Listing ALL items in your main inventory:");
+ int itemNum = 1;
+
+ foreach (WorldObject item in worldFilter.GetByContainer(playerInv))
{
- if (args[1].Equals("enable", StringComparison.OrdinalIgnoreCase))
+ if (!string.IsNullOrEmpty(item.Name))
{
- PluginSettings.Instance.HttpServerEnabled = true;
- HttpServerEnabled = true;
- HttpCommandServer.Start();
- }
- else if (args[1].Equals("disable", StringComparison.OrdinalIgnoreCase))
- {
- PluginSettings.Instance.HttpServerEnabled = false;
- HttpServerEnabled = false;
- HttpCommandServer.Stop();
- }
- else
- {
- WriteToChat("Usage: /mm http ");
+ int stackCount = item.Values(LongValueKey.StackCount, 0);
+ WriteToChat($"{itemNum:D2}: '{item.Name}' (count: {stackCount}, icon: 0x{item.Icon:X}, class: {item.ObjectClass})");
+ itemNum++;
+
+ string nameLower = item.Name.ToLower();
+ if (nameLower.Contains("taper") || nameLower.Contains("prismatic") ||
+ nameLower.Contains("prism") || nameLower.Contains("component"))
+ {
+ WriteToChat($" *** POSSIBLE MATCH: '{item.Name}' ***");
+ }
}
}
+
+ WriteToChat($"=== Total items listed: {itemNum - 1} ===");
+
+ WriteToChat("=== Testing Utility Functions on Prismatic Taper ===");
+ var foundItem = Utils.FindItemByName("Prismatic Taper");
+ if (foundItem != null)
+ {
+ WriteToChat($"SUCCESS! Found: '{foundItem.Name}'");
+ WriteToChat($"Utils.GetItemStackSize: {Utils.GetItemStackSize("Prismatic Taper")}");
+ WriteToChat($"Utils.GetItemIcon: 0x{Utils.GetItemIcon("Prismatic Taper"):X}");
+ WriteToChat($"Utils.GetItemDisplayIcon: 0x{Utils.GetItemDisplayIcon("Prismatic Taper"):X}");
+ WriteToChat("=== TELEMETRY WILL NOW WORK! ===");
+ }
else
{
- WriteToChat("Usage: /mm http ");
+ WriteToChat("ERROR: Still can't find Prismatic Taper with utility functions!");
}
- break;
+ }
+ catch (Exception ex)
+ {
+ WriteToChat($"Search error: {ex.Message}");
+ }
+ }, "");
- case "remotecommands":
- if (args.Length > 1)
+ _commandRouter.Register("deathstats", args =>
+ {
+ try
+ {
+ WriteToChat("=== Death Tracking Statistics ===");
+ WriteToChat($"Session Deaths: {_killTracker.SessionDeaths}");
+ WriteToChat($"Total Deaths: {_killTracker.TotalDeaths}");
+
+ int currentCharDeaths = CoreManager.Current.CharacterFilter.GetCharProperty((int)IntValueKey.NumDeaths);
+ WriteToChat($"Character Property NumDeaths: {currentCharDeaths}");
+
+ if (currentCharDeaths != _killTracker.TotalDeaths)
{
- if (args[1].Equals("enable", StringComparison.OrdinalIgnoreCase))
- {
- PluginSettings.Instance.RemoteCommandsEnabled = true;
- RemoteCommandsEnabled = true;
- WriteToChat("Remote command listening is now ENABLED.");
- }
- else if (args[1].Equals("disable", StringComparison.OrdinalIgnoreCase))
- {
- PluginSettings.Instance.RemoteCommandsEnabled = false;
- RemoteCommandsEnabled = false;
- WriteToChat("Remote command listening is now DISABLED.");
- }
- else
- {
- WriteToChat("Invalid remotecommands argument. Use 'enable' or 'disable'.");
- }
+ WriteToChat($"[WARNING] Death count sync issue detected!");
+ WriteToChat($"Updating totalDeaths from {_killTracker.TotalDeaths} to {currentCharDeaths}");
+ _killTracker.SetTotalDeaths(currentCharDeaths);
+ }
+
+ WriteToChat("Death tracking is active and will increment on character death.");
+ }
+ catch (Exception ex)
+ {
+ WriteToChat($"Death stats error: {ex.Message}");
+ }
+ }, "Show death tracking stats");
+
+ _commandRouter.Register("testdeath", args =>
+ {
+ try
+ {
+ WriteToChat("=== Manual Death Test ===");
+ WriteToChat($"Current sessionDeaths variable: {_killTracker.SessionDeaths}");
+ WriteToChat($"Current totalDeaths variable: {_killTracker.TotalDeaths}");
+
+ int currentCharDeaths = CoreManager.Current.CharacterFilter.GetCharProperty((int)IntValueKey.NumDeaths);
+ WriteToChat($"Character Property NumDeaths (43): {currentCharDeaths}");
+
+ _killTracker.OnDeath();
+ WriteToChat($"Manually incremented sessionDeaths to: {_killTracker.SessionDeaths}");
+ WriteToChat("Note: This doesn't simulate a real death, just tests the tracking variables.");
+
+ WriteToChat($"Death event subscription check:");
+ var deathEvent = typeof(Decal.Adapter.Wrappers.CharacterFilter).GetEvent("Death");
+ WriteToChat($"Death event exists: {deathEvent != null}");
+ }
+ catch (Exception ex)
+ {
+ WriteToChat($"Test death error: {ex.Message}");
+ }
+ }, "");
+
+ _commandRouter.Register("testtaper", args =>
+ {
+ try
+ {
+ WriteToChat("=== Cached Taper Tracking Test ===");
+ WriteToChat($"Cached Count: {_inventoryMonitor.CachedPrismaticCount}");
+ WriteToChat($"Last Count: {_inventoryMonitor.LastPrismaticCount}");
+
+ int utilsCount = Utils.GetItemStackSize("Prismatic Taper");
+ WriteToChat($"Utils Count: {utilsCount}");
+
+ if (_inventoryMonitor.CachedPrismaticCount == utilsCount)
+ {
+ WriteToChat("[OK] Cached count matches Utils count");
}
else
{
- WriteToChat("Usage: /mm remotecommands ");
+ WriteToChat($"[WARNING] Count mismatch! Cached: {_inventoryMonitor.CachedPrismaticCount}, Utils: {utilsCount}");
+ WriteToChat("Refreshing cached count...");
+ _inventoryMonitor.Initialize();
}
- break;
- default:
- WriteToChat($"Unknown /mm command: {subCommand}. Try /mm help");
- break;
- }
+ WriteToChat("=== Container Analysis ===");
+ int mainPackCount = 0;
+ int sidePackCount = 0;
+ int playerId = CoreManager.Current.CharacterFilter.Id;
+
+ foreach (WorldObject wo in CoreManager.Current.WorldFilter.GetInventory())
+ {
+ if (wo.Name.Equals("Prismatic Taper", StringComparison.OrdinalIgnoreCase))
+ {
+ int stackCount = wo.Values(LongValueKey.StackCount, 1);
+ if (wo.Container == playerId)
+ mainPackCount += stackCount;
+ else
+ sidePackCount += stackCount;
+ }
+ }
+
+ WriteToChat($"Main Pack Tapers: {mainPackCount}");
+ WriteToChat($"Side Pack Tapers: {sidePackCount}");
+ WriteToChat($"Total: {mainPackCount + sidePackCount}");
+
+ WriteToChat("=== Event System Status ===");
+ WriteToChat($"Tracking {_inventoryMonitor.TrackedTaperCount} taper stacks for delta detection");
+ WriteToChat($"Known stack sizes: {_inventoryMonitor.KnownStackSizesCount} items");
+ WriteToChat("Pure delta tracking - NO expensive inventory scans during events!");
+ WriteToChat("Now tracks: consumption, drops, trades, container moves");
+ WriteToChat("Try moving tapers between containers and casting spells!");
+ }
+ catch (Exception ex)
+ {
+ WriteToChat($"Taper test error: {ex.Message}");
+ }
+ }, "");
+
+ _commandRouter.Register("finditem", args =>
+ {
+ if (args.Length > 1)
+ {
+ string itemName = string.Join(" ", args, 1, args.Length - 1).Trim('"');
+ WriteToChat($"=== Searching for: '{itemName}' ===");
+
+ var foundItem = Utils.FindItemByName(itemName);
+ if (foundItem != null)
+ {
+ WriteToChat($"FOUND: '{foundItem.Name}'");
+ WriteToChat($"Count: {foundItem.Values(LongValueKey.StackCount, 0)}");
+ WriteToChat($"Icon: 0x{foundItem.Icon:X}");
+ WriteToChat($"Display Icon: 0x{(foundItem.Icon + 0x6000000):X}");
+ WriteToChat($"Object Class: {foundItem.ObjectClass}");
+ }
+ else
+ {
+ WriteToChat($"NOT FOUND: '{itemName}'");
+ WriteToChat("Make sure the name is exactly as it appears in-game.");
+ }
+ }
+ else
+ {
+ WriteToChat("Usage: /mm finditem \"Item Name\"");
+ WriteToChat("Example: /mm finditem \"Prismatic Taper\"");
+ }
+ }, "Find item in inventory");
+
+ _commandRouter.Register("checkforupdate", args =>
+ {
+ Task.Run(async () =>
+ {
+ await UpdateManager.CheckForUpdateAsync();
+ try
+ {
+ ViewManager.RefreshUpdateStatus();
+ }
+ catch (Exception ex)
+ {
+ WriteToChat($"Error refreshing UI: {ex.Message}");
+ }
+ });
+ }, "Check for plugin updates");
+
+ _commandRouter.Register("update", args =>
+ {
+ Task.Run(async () =>
+ {
+ await UpdateManager.DownloadAndInstallUpdateAsync();
+ });
+ }, "Download and install latest update");
+
+ _commandRouter.Register("debugupdate", args =>
+ {
+ Views.VVSTabbedMainView.DebugUpdateControls();
+ }, "");
+
+ _commandRouter.Register("sendinventory", args =>
+ {
+ if (_inventoryLogger != null)
+ _inventoryLogger.ForceInventoryUpload();
+ else
+ WriteToChat("[INV] Inventory system not initialized");
+ }, "Force full inventory upload");
+
+ _commandRouter.Register("refreshquests", args =>
+ {
+ try
+ {
+ WriteToChat("[QUEST] Refreshing quest data...");
+ Views.FlagTrackerView.RefreshQuestData();
+ }
+ catch (Exception ex)
+ {
+ WriteToChat($"[QUEST] Refresh failed: {ex.Message}");
+ }
+ }, "Refresh quest data");
+
+ _commandRouter.Register("queststatus", args =>
+ {
+ try
+ {
+ WriteToChat("=== Quest Streaming Status ===");
+ WriteToChat($"Timer Active: {_questStreamingService?.IsRunning ?? false}");
+ WriteToChat($"WebSocket Enabled: {WebSocketEnabled}");
+ WriteToChat($"Quest Manager: {(questManager != null ? "Active" : "Not Active")}");
+ WriteToChat($"Quest Count: {questManager?.QuestList?.Count ?? 0}");
+
+ if (questManager?.QuestList != null)
+ {
+ var priorityQuests = questManager.QuestList
+ .Where(q => QuestStreamingService.IsHighPriorityQuest(q.Id))
+ .GroupBy(q => q.Id)
+ .Select(g => g.First())
+ .ToList();
+ WriteToChat($"Priority Quests Found: {priorityQuests.Count}");
+ foreach (var quest in priorityQuests)
+ {
+ string questName = questManager.GetFriendlyQuestName(quest.Id);
+ WriteToChat($" - {questName} ({quest.Id})");
+ }
+ }
+
+ WriteToChat($"Verbose Logging: {PluginSettings.Instance?.VerboseLogging ?? false}");
+ WriteToChat("Use '/mm verbose' to toggle debug logging");
+ }
+ catch (Exception ex)
+ {
+ WriteToChat($"[QUEST] Status check failed: {ex.Message}");
+ }
+ }, "Show quest streaming status");
+
+ _commandRouter.Register("verbose", args =>
+ {
+ if (PluginSettings.Instance != null)
+ {
+ PluginSettings.Instance.VerboseLogging = !PluginSettings.Instance.VerboseLogging;
+ WriteToChat($"Verbose logging: {(PluginSettings.Instance.VerboseLogging ? "ENABLED" : "DISABLED")}");
+ }
+ else
+ {
+ WriteToChat("Settings not initialized");
+ }
+ }, "Toggle verbose debug logging");
}
diff --git a/MosswartMassacre/PluginSettings.cs b/MosswartMassacre/PluginSettings.cs
index e59f09e..8104493 100644
--- a/MosswartMassacre/PluginSettings.cs
+++ b/MosswartMassacre/PluginSettings.cs
@@ -13,25 +13,60 @@ namespace MosswartMassacre
private static readonly object _sync = new object();
// backing fields
- private bool _remoteCommandsEnabled = false;
private bool _rareMetaEnabled = true;
- private bool _httpServerEnabled = false;
- private bool _telemetryEnabled = false;
+ private bool _webSocketEnabled = false;
+ private bool _inventorylog = true;
private string _charTag = "default";
+ private int _mainWindowX = 100;
+ private int _mainWindowY = 100;
+ private bool _useTabbedInterface = true;
+ private string _vtankProfilesPath = "";
+ private bool _verboseLogging = false;
+ private ChestLooterSettings _chestLooterSettings = new ChestLooterSettings();
public static PluginSettings Instance => _instance
?? throw new InvalidOperationException("PluginSettings not initialized");
public static void Initialize()
{
- // determine settings file path
+ // determine plugin folder and character-specific folder
string characterName = CoreManager.Current.CharacterFilter.Name;
- string pluginFolder = Path.GetDirectoryName(System.Reflection.Assembly.GetExecutingAssembly().Location);
- _filePath = Path.Combine(pluginFolder, $"{characterName}.yaml");
+
+ // For hot reload scenarios, use the AssemblyDirectory set by the Loader
+ // For normal loading, fall back to the executing assembly location
+ string pluginFolder;
+ if (!string.IsNullOrEmpty(PluginCore.AssemblyDirectory))
+ {
+ pluginFolder = PluginCore.AssemblyDirectory;
+ }
+ else
+ {
+ pluginFolder = Path.GetDirectoryName(System.Reflection.Assembly.GetExecutingAssembly().Location);
+ }
+
+ // Path for character-specific folder
+ string characterFolder = Path.Combine(pluginFolder, characterName);
+
+ // Create the character folder if it doesn't exist
+ if (!Directory.Exists(characterFolder))
+ {
+ try
+ {
+ Directory.CreateDirectory(characterFolder);
+ }
+ catch (Exception ex)
+ {
+ PluginCore.DispatchChatToBoxWithPluginIntercept($"[Settings] Failed to create character folder: {ex.Message}");
+ }
+ }
+
+ // YAML file is now in the character-specific folder
+ _filePath = Path.Combine(characterFolder, $"{characterName}.yaml");
// build serializer/deserializer once
var builder = new DeserializerBuilder()
- .WithNamingConvention(UnderscoredNamingConvention.Instance);
+ .WithNamingConvention(UnderscoredNamingConvention.Instance)
+ .IgnoreUnmatchedProperties();
var deserializer = builder.Build();
PluginSettings loaded = null;
@@ -100,34 +135,73 @@ namespace MosswartMassacre
}
// public properties
- public bool RemoteCommandsEnabled
- {
- get => _remoteCommandsEnabled;
- set { _remoteCommandsEnabled = value; Save(); }
- }
-
public bool RareMetaEnabled
{
get => _rareMetaEnabled;
set { _rareMetaEnabled = value; Save(); }
}
- public bool HttpServerEnabled
+ public bool WebSocketEnabled
{
- get => _httpServerEnabled;
- set { _httpServerEnabled = value; Save(); }
+ get => _webSocketEnabled;
+ set { _webSocketEnabled = value; Save(); }
}
-
- public bool TelemetryEnabled
- {
- get => _telemetryEnabled;
- set { _telemetryEnabled = value; Save(); }
- }
-
public string CharTag
{
get => _charTag;
set { _charTag = value; Save(); }
}
+ public bool InventoryLog
+ {
+ get => _inventorylog;
+ set { _inventorylog = value; Save(); }
+ }
+
+ public int MainWindowX
+ {
+ get => _mainWindowX;
+ set { _mainWindowX = value; Save(); }
+ }
+
+ public int MainWindowY
+ {
+ get => _mainWindowY;
+ set { _mainWindowY = value; Save(); }
+ }
+
+ public bool UseTabbedInterface
+ {
+ get => _useTabbedInterface;
+ set { _useTabbedInterface = value; Save(); }
+ }
+
+ public string VTankProfilesPath
+ {
+ get => _vtankProfilesPath;
+ set { _vtankProfilesPath = value; Save(); }
+ }
+
+ public bool VerboseLogging
+ {
+ get => _verboseLogging;
+ set { _verboseLogging = value; Save(); }
+ }
+
+ public ChestLooterSettings ChestLooterSettings
+ {
+ get
+ {
+ if (_chestLooterSettings == null)
+ {
+ _chestLooterSettings = new ChestLooterSettings();
+ }
+ return _chestLooterSettings;
+ }
+ set
+ {
+ _chestLooterSettings = value;
+ Save();
+ }
+ }
}
}
diff --git a/MosswartMassacre/Properties/AssemblyInfo.cs b/MosswartMassacre/Properties/AssemblyInfo.cs
index 744091c..216b745 100644
--- a/MosswartMassacre/Properties/AssemblyInfo.cs
+++ b/MosswartMassacre/Properties/AssemblyInfo.cs
@@ -21,10 +21,4 @@ using System.Runtime.InteropServices;
// The following GUID is for the ID of the typelib if this project is exposed to COM
[assembly: Guid("9b6a07e1-ae78-47f4-b09c-174f6a27d7a3")]
-// Version information for an assembly consists of the following four values:
-// Major Version
-// Minor Version
-// Build Number
-// Revision
-[assembly: AssemblyVersion("2.0.0.0")]
-[assembly: AssemblyFileVersion("2.0.0.0")]
\ No newline at end of file
+// Version is auto-generated at build time (CalVer: YYYY.M.D.HHmm)
\ No newline at end of file
diff --git a/MosswartMassacre/QuestManager.cs b/MosswartMassacre/QuestManager.cs
new file mode 100644
index 0000000..a357e51
--- /dev/null
+++ b/MosswartMassacre/QuestManager.cs
@@ -0,0 +1,313 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text.RegularExpressions;
+using Decal.Adapter;
+using Decal.Adapter.Wrappers;
+
+namespace MosswartMassacre
+{
+ ///
+ /// Quest tracking and management system
+ /// Ported from UBS Lua quest system
+ ///
+ public class QuestManager : IDisposable
+ {
+ #region Quest Data Structures
+ public class Quest
+ {
+ public string Id { get; set; }
+ public int Solves { get; set; }
+ public int Timestamp { get; set; }
+ public string Description { get; set; }
+ public int MaxSolves { get; set; }
+ public int Delta { get; set; }
+ public int ExpireTime { get; set; }
+ }
+ #endregion
+
+ #region Properties
+ public List QuestList { get; private set; }
+ public Dictionary QuestDictionary { get; private set; }
+ #endregion
+
+ #region Events and State
+ private bool isRefreshing = false;
+ private DateTime lastRefreshTime = DateTime.MinValue;
+ #endregion
+
+ public QuestManager()
+ {
+ QuestList = new List();
+ QuestDictionary = new Dictionary();
+
+ // Hook into chat events for quest parsing
+ InitializeChatHooks();
+ }
+
+ #region Initialization
+ private void InitializeChatHooks()
+ {
+ try
+ {
+ if (CoreManager.Current != null)
+ {
+ CoreManager.Current.ChatBoxMessage += OnChatBoxMessage;
+ }
+ }
+ catch (Exception ex)
+ {
+ PluginCore.WriteToChat($"Error initializing quest chat hooks: {ex.Message}");
+ }
+ }
+ #endregion
+
+ #region Quest Name Mapping
+ public string GetFriendlyQuestName(string questStamp)
+ {
+ return QuestNames.GetFriendlyName(questStamp);
+ }
+
+ public string GetQuestDisplayName(string questStamp)
+ {
+ return QuestNames.GetDisplayName(questStamp);
+ }
+
+ public int GetQuestNameMappingsCount()
+ {
+ return QuestNames.QuestStampToName.Count;
+ }
+ #endregion
+
+ #region Quest Parsing
+ private void OnChatBoxMessage(object sender, ChatTextInterceptEventArgs e)
+ {
+ try
+ {
+ if (!isRefreshing || string.IsNullOrEmpty(e.Text))
+ return;
+
+ // Parse quest information from /myquests output
+ ParseQuestLine(e.Text);
+ }
+ catch (Exception ex)
+ {
+ PluginCore.WriteToChat($"Error parsing quest line: {ex.Message}");
+ }
+ }
+
+ private void ParseQuestLine(string text)
+ {
+ try
+ {
+ // Quest line format: TaskName - Solves solves (Timestamp)"Description" MaxSolves Delta
+ // Example: "SomeQuest - 5 solves (1640995200)"Quest description here" 10 3600
+ var pattern = @"([^\-]+) - (\d+) solves \((\d+)\)""([^""]+)"" (-?\d+) (\d+)";
+ var match = Regex.Match(text, pattern);
+
+ if (match.Success)
+ {
+ var quest = new Quest
+ {
+ Id = match.Groups[1].Value.Trim(),
+ Solves = int.Parse(match.Groups[2].Value),
+ Timestamp = int.Parse(match.Groups[3].Value),
+ Description = match.Groups[4].Value,
+ MaxSolves = int.Parse(match.Groups[5].Value),
+ Delta = int.Parse(match.Groups[6].Value)
+ };
+
+ quest.ExpireTime = quest.Timestamp + quest.Delta;
+
+ // Add to collections
+ QuestList.Add(quest);
+ QuestDictionary[quest.Id] = quest;
+ }
+ }
+ catch (Exception ex)
+ {
+ PluginCore.WriteToChat($"Error parsing quest line '{text}': {ex.Message}");
+ }
+ }
+ #endregion
+
+ #region Quest Management
+ public void RefreshQuests()
+ {
+ try
+ {
+ if (isRefreshing)
+ return;
+
+ ClearQuests();
+ isRefreshing = true;
+
+ // Issue /myquests command to refresh quest data
+ CoreManager.Current.Actions.InvokeChatParser("/myquests");
+
+ // Stop listening after a delay
+ System.Threading.Timer stopTimer = null;
+ stopTimer = new System.Threading.Timer(_ =>
+ {
+ isRefreshing = false;
+ stopTimer?.Dispose();
+ lastRefreshTime = DateTime.Now;
+ }, null, 3000, System.Threading.Timeout.Infinite);
+ }
+ catch (Exception ex)
+ {
+ isRefreshing = false;
+ PluginCore.WriteToChat($"Error refreshing quests: {ex.Message}");
+ }
+ }
+
+ public void ClearQuests()
+ {
+ QuestList.Clear();
+ QuestDictionary.Clear();
+ }
+
+ public bool IsQuestAvailable(string questStamp)
+ {
+ if (!QuestDictionary.TryGetValue(questStamp, out Quest quest))
+ return true; // If quest not found, assume available
+
+ var currentTime = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
+ return quest.ExpireTime < currentTime;
+ }
+
+ public bool IsQuestMaxSolved(string questStamp)
+ {
+ if (!QuestDictionary.TryGetValue(questStamp, out Quest quest))
+ return false;
+
+ return quest.Solves >= quest.MaxSolves;
+ }
+
+ public bool HasQuestFlag(string questStamp)
+ {
+ return QuestDictionary.ContainsKey(questStamp);
+ }
+
+ public string GetTimeUntilExpire(Quest quest)
+ {
+ if (quest == null)
+ return "Unknown";
+
+ var currentTime = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
+ var timeLeft = quest.ExpireTime - currentTime;
+
+ if (timeLeft <= 0)
+ return "Ready";
+
+ return FormatSeconds((int)timeLeft);
+ }
+
+ public string FormatTimeStamp(int timestamp)
+ {
+ try
+ {
+ var dateTime = DateTimeOffset.FromUnixTimeSeconds(timestamp).DateTime;
+ return dateTime.ToString("MM/dd/yyyy HH:mm:ss");
+ }
+ catch
+ {
+ return "Invalid";
+ }
+ }
+
+ public string FormatSeconds(int seconds)
+ {
+ if (seconds <= 0)
+ return "0s";
+
+ var days = seconds / 86400;
+ seconds %= 86400;
+ var hours = seconds / 3600;
+ seconds %= 3600;
+ var minutes = seconds / 60;
+ seconds %= 60;
+
+ var result = "";
+ if (days > 0) result += $"{days}d ";
+ if (hours > 0) result += $"{hours}h ";
+ if (minutes > 0) result += $"{minutes}m ";
+ if (seconds > 0 || string.IsNullOrEmpty(result)) result += $"{seconds}s";
+
+ return result.Trim();
+ }
+
+ public object GetFieldByID(Quest quest, int id)
+ {
+ if (quest == null)
+ return null;
+
+ switch (id)
+ {
+ case 1: return quest.Id;
+ case 2: return quest.Solves;
+ case 3: return quest.Timestamp;
+ case 4: return quest.MaxSolves;
+ case 5: return quest.Delta;
+ case 6: return quest.ExpireTime;
+ default: return quest.Id;
+ }
+ }
+ #endregion
+
+ #region Society Quest Helpers
+ public string GetSocietyName(int factionBits)
+ {
+ switch (factionBits)
+ {
+ case 1: return "Celestial Hand";
+ case 2: return "Eldrytch Web";
+ case 4: return "Radiant Blood";
+ default: return "Unknown";
+ }
+ }
+
+ public string GetSocietyRank(int ribbons)
+ {
+ if (ribbons >= 1001) return "Master";
+ if (ribbons >= 601) return "Lord";
+ if (ribbons >= 301) return "Knight";
+ if (ribbons >= 101) return "Adept";
+ if (ribbons >= 1) return "Initiate";
+ return "None";
+ }
+
+ public int GetMaxRibbonsPerDay(string rank)
+ {
+ switch (rank)
+ {
+ case "Initiate": return 50;
+ case "Adept": return 100;
+ case "Knight": return 150;
+ case "Lord": return 200;
+ case "Master": return 250;
+ default: return 0;
+ }
+ }
+ #endregion
+
+ #region Cleanup
+ public void Dispose()
+ {
+ try
+ {
+ if (CoreManager.Current != null)
+ {
+ CoreManager.Current.ChatBoxMessage -= OnChatBoxMessage;
+ }
+
+ ClearQuests();
+ }
+ catch (Exception ex)
+ {
+ PluginCore.WriteToChat($"Error disposing quest manager: {ex.Message}");
+ }
+ }
+ #endregion
+ }
+}
\ No newline at end of file
diff --git a/MosswartMassacre/QuestNames.cs b/MosswartMassacre/QuestNames.cs
new file mode 100644
index 0000000..9c782b8
--- /dev/null
+++ b/MosswartMassacre/QuestNames.cs
@@ -0,0 +1,228 @@
+using System.Collections.Generic;
+
+namespace MosswartMassacre
+{
+ ///
+ /// Static quest name mappings from quest stamp to friendly display name
+ /// Based on questtracker repository data
+ ///
+ public static class QuestNames
+ {
+ ///
+ /// Dictionary mapping quest stamps to friendly quest names
+ ///
+ public static readonly Dictionary QuestStampToName = new Dictionary
+ {
+ // Character-specific Quest Stamps (from actual /myquests output)
+ ["30minattributes"] = "30 Minute Attribute Gems Timer",
+ ["academeyexittokengiven"] = "Academy Exit Token Received",
+ ["aerbaxchestkey2pickup"] = "Aerbax Chest Key #2 Pickup",
+ ["anekshaygemofknowledgetimer_monthly"] = "A'nekshay Gem of Knowledge Monthly Timer",
+ ["anekshaygemoflesserknowledgecollectedinamonth"] = "A'nekshay Gems of Lesser Knowledge Monthly Count",
+ ["anekshaygemoflesserknowledgetimer_monthly"] = "A'nekshay Gem of Lesser Knowledge Monthly Timer",
+ ["attributereset30day"] = "30-Day Attribute Reset Timer",
+ ["augmentationblankgemacquired"] = "Blank Augmentation Gem Pickup Timer",
+ ["bellowsnewbieturnedin"] = "Blacksmith's Bellows Turned In",
+ ["bonecrunchkeypickuptimer"] = "Bonecrunch's Key Pickup Timer",
+ ["callingstonegiven"] = "Calling Stone Turned Over",
+ ["defeatedbonecrunch"] = "Bonecrunch Defeated",
+ ["efmlcentermanafieldused"] = "EF Middle Level Center Mana Field Used",
+ ["efmleastmanafieldused"] = "EF Middle Level East Mana Field Used",
+ ["efmlnorthmanafieldused"] = "EF Middle Level North Mana Field Used",
+ ["efmlsouthmanafieldused"] = "EF Middle Level South Mana Field Used",
+ ["efmlwestmanafieldused"] = "EF Middle Level West Mana Field Used",
+ ["efulcentermanafieldused"] = "EF Upper Level Center Mana Field Used",
+ ["efuleastmanafieldused"] = "EF Upper Level East Mana Field Used",
+ ["efulnorthmanafieldused"] = "EF Upper Level North Mana Field Used",
+ ["efulsouthmanafieldused"] = "EF Upper Level South Mana Field Used",
+ ["efulwestmanafieldused"] = "EF Upper Level West Mana Field Used",
+ ["insatiableeaterjaw"] = "Insatiable Eater Jaw Collection",
+ ["pathwardencomplete"] = "Pathwarden Visit Complete",
+ ["pathwardenfound1111"] = "Pathwarden Greeter Encountered",
+ ["recallsingularitycaul"] = "Recall Singularity Bore Pickup",
+ ["stipendscollectedinamonth"] = "Monthly Stipends Collected Count",
+ ["stipendtimer_0812"] = "Stipend Collection Timer",
+ ["stipendtimer_monthly"] = "Monthly Stipend Timer",
+ ["upperinsatiablejaw"] = "Upper Insatiable Eater Jaw Collection",
+ ["usedattributereset"] = "Attribute Reset Used",
+ ["usedfreeattributereset"] = "Free Attribute Reset Used",
+ ["usedfreeskillreset"] = "Free Skill Reset Used",
+ ["usedskillreset"] = "Skill Reset Used",
+ ["virindiisland"] = "Singularity Island Visit",
+
+ // Kill Tasks
+ ["turshscalp"] = "Tursh Scalp",
+ ["polarursuin"] = "Polar Ursuin Kill Task Main Flag Timer",
+ ["polarursuinkillcount"] = "Polar Ursuin Kill Counter",
+ ["polardillotask"] = "Polar Dillo Kill Task Main Flag",
+ ["polardillokills"] = "Polar Dillo Kill Counter",
+ ["repugnanteaterkilltask"] = "Repugnant Eater Kill Task",
+ ["repugeaterkillcount"] = "Repugnant Eater Kill Counter",
+ ["deathcap"] = "Deathcap Thrungus Kill Task",
+ ["deathcapkillcount"] = "Deathcap Thrungus Kill Counter",
+ ["grievverv"] = "Grievver Violator Kill Task",
+ ["grievvervkillcount"] = "Grievver Violator Kill Counter",
+ ["tuskerg"] = "Tusker Guard Kill Task Main Flag",
+ ["tuskergkillcount"] = "Tusker Guard Kill Counter",
+
+ // Quest Timers and Pickups
+ ["blankaug"] = "Blank Aug Gem Pickup Timer",
+ ["greatcavepenguinegg"] = "Great Cave Penguin Egg Pickup Timer",
+ ["deathallurecd"] = "Death's Allure Timer Flag",
+ ["brewmastercover"] = "Brew Master Quest Pickup Timer Cover",
+ ["brewmasterback"] = "Brew Master Quest Pickup Timer Back",
+ ["brewmasterpages"] = "Brew Master Quest Pickup Timer Pages",
+ ["brewmasterspine"] = "Brew Master Quest Pickup Timer Spine",
+ ["eleonorasheart"] = "Elanora's Heart Quest Pickup Timer",
+ ["beacongemobtained"] = "Cooldown for obtaining another beacon gem",
+ ["beaconcomplete"] = "Beacon Quest Complete Timer",
+ ["sirginaziosword"] = "Pick up of Sir Ginazio's Sword",
+
+ // Major Quests
+ ["maraudersjaw"] = "Marauder's Lair Quest",
+ ["fledgemastertusk"] = "Fledge Master's Tusk Quest",
+ ["crystallinekiller"] = "Crystalline Killer",
+ ["darkisledelivery"] = "Dark Isle Delivery",
+ ["defeatingvaeshok"] = "Defeating Vaeshok",
+ ["hollyjollyhelperquest"] = "Holly Jolly Helper Quest",
+ ["moarsmenjailbreak"] = "Moarsmen Jailbreak",
+ ["shamblingarchivistdestroyer"] = "Shambling Archivist Destroyer",
+ ["tracingthestone"] = "Tracing The Stone",
+ ["undeadjawcollection"] = "Undead Jaw Collection",
+ ["weedingofthederutree"] = "Weeding of the Deru Tree",
+ ["ironbladecommander"] = "Iron Blade Commander",
+ ["mumiyahhuntingneftet"] = "Mumiyah Hunting Neftet",
+ ["torgashstasks"] = "Torgash's Tasks",
+
+ // Thrungus Hovels Items
+ ["stolenfryingpan"] = "Thrungus Hovels",
+ ["stolenring"] = "Thrungus Hovels",
+ ["stolenbrewkettle"] = "Thrungus Hovels",
+ ["stolenamulet"] = "Thrungus Hovels",
+ ["stolenewer"] = "Thrungus Hovels",
+ ["stolennecklace"] = "Thrungus Hovels",
+ ["stolenplatter"] = "Thrungus Hovels",
+ ["stolenbracelet"] = "Thrungus Hovels",
+
+ // Special Items and Flags
+ ["ringofkarlun"] = "Knights of Karlun",
+ ["trainingacademycomplete"] = "Completion of Training Academy for Exit",
+ ["cowtipcounter"] = "Counter for Cow Tipping",
+ ["cowtip"] = "Main Timed Flag for Cow Tipping",
+ ["skillloweringgempickedup"] = "Picked up a forgetfulness gem",
+
+ // Healing Machine Components
+ ["orbhealingmachine"] = "Healing Machine Orb",
+ ["pedestalhealingmachine"] = "Healing Machine Pedestal",
+ ["tihnhealingmachine"] = "Healing Machine Tihn",
+ ["lavushealingmachine"] = "Healing Machine Lavus",
+ ["hookhealingmachine"] = "Healing Machine Hook",
+
+ // Eater Jaws
+ ["ravenouseaterjaw"] = "Ravenous Eater Jaw",
+ ["insatiableeaterjaw"] = "Insatiable Eater Jaw",
+ ["engorgedeaterjaw"] = "Engorged Eater Jaw",
+ ["voraciouseaterjaw"] = "Voracious Eater Jaw",
+ ["abhorrenteaterjaw"] = "Abhorrent Eater Jaw",
+
+ // Kill Tasks (Extended)
+ ["altereddrudgekilltask"] = "Altered Drudge Kill Task",
+ ["altereddrudgekillcount"] = "Altered Drudge Kill Counter",
+ ["arcticmattekarkilltask"] = "Arctic Mattekar Kill Task",
+ ["arcticmattekarkillcount"] = "Arctic Mattekar Kill Counter",
+ ["armoredillohuntingneftetkilltask"] = "Armoredillo Hunting Neftet Kill Task",
+ ["armoredillohuntingneftetkillcount"] = "Armoredillo Hunting Neftet Kill Counter",
+ ["augmenteddrudgekilltask"] = "Augmented Drudge Kill Task",
+ ["augmenteddrudgekillcount"] = "Augmented Drudge Kill Counter",
+ ["banishedcreaturekilltask"] = "Banished Creature Kill Task",
+ ["banishedcreaturekillcount"] = "Banished Creature Kill Counter",
+ ["benekniffiskilltask"] = "Benek Niffis Kill Task",
+ ["benekniffiskillcount"] = "Benek Niffis Kill Counter",
+ ["blackcoralgolemkilltask"] = "Black Coral Golem Kill Task",
+ ["blackcoralgolemkillcount"] = "Black Coral Golem Kill Counter",
+ ["blessedmoarsmankilltask"] = "Blessed Moarsman Kill Task",
+ ["blessedmoarsmankillcount"] = "Blessed Moarsman Kill Counter",
+ ["blightedcoralgolemkilltask"] = "Blighted Coral Golem Kill Task",
+ ["blightedcoralgolemkillcount"] = "Blighted Coral Golem Kill Counter",
+ ["bloodshrethkilltask"] = "Blood Shreth Kill Task",
+ ["bloodshrethkillcount"] = "Blood Shreth Kill Counter",
+ ["bronzegauntlettrooperkilltask"] = "Bronze Gauntlet Trooper Kill Task",
+ ["bronzegauntlettrooperkillcount"] = "Bronze Gauntlet Trooper Kill Counter",
+ ["coppercogtrooperkilltask"] = "Copper Cog Trooper Kill Task",
+ ["coppercogtrooperkillcount"] = "Copper Cog Trooper Kill Counter",
+ ["coppergolemkingpinkilltask"] = "Copper Golem Kingpin Kill Task",
+ ["coppergolemkingpinkillcount"] = "Copper Golem Kingpin Kill Counter",
+ ["coralgolemkilltask"] = "Coral Golem Kill Task",
+ ["coralgolemkillcount"] = "Coral Golem Kill Counter",
+ ["coralgolemviceroykilltask"] = "Coral Golem Viceroy Kill Task",
+ ["coralgolemviceroykillcount"] = "Coral Golem Viceroy Kill Counter",
+ ["corruptedgravestonekilltask"] = "Corrupted Gravestone Kill Task",
+ ["corruptedgravestonekillcount"] = "Corrupted Gravestone Kill Counter",
+ ["deathcapthrunguskilltask"] = "Deathcap Thrungus Kill Task",
+ ["deathcapthrunguskillcount"] = "Deathcap Thrungus Kill Counter",
+ ["desertcactuskilltask"] = "Desert Cactus Kill Task",
+ ["desertcactuskillcount"] = "Desert Cactus Kill Counter",
+ ["devourermargulkilltask"] = "Devourer Margul Kill Task",
+ ["devourermargulkillcount"] = "Devourer Margul Kill Counter",
+
+ // Society and Faction Quests
+ ["celestialhandintroductioncomplete"] = "Celestial Hand Introduction Complete",
+ ["eldrytchwebintroductioncomplete"] = "Eldrytch Web Introduction Complete",
+ ["radiantbloodintroductioncomplete"] = "Radiant Blood Introduction Complete",
+ ["celestialhandinitiatetest"] = "Celestial Hand Initiate Test",
+ ["eldrytchwebinitiatetest"] = "Eldrytch Web Initiate Test",
+ ["radiantbloodinitiatetest"] = "Radiant Blood Initiate Test",
+
+ // Luminance Aura Related
+ ["aetheriaredemption"] = "Aetheria Redemption",
+ ["aegisofmerc"] = "Aegis of Merc",
+ ["lumaugtradein"] = "Luminance Augmentation Trade In",
+
+ // Common AC Quests
+ ["holtburgtraderskill"] = "Holtburg Trader Skill Quest",
+ ["shoushitraderskill"] = "Shoushi Trader Skill Quest",
+ ["yaraqtraderskill"] = "Yaraq Trader Skill Quest",
+ ["newbiequests"] = "Newbie Academy Quests",
+ ["moarsmanraid"] = "Moarsman Raid",
+ ["virindiparadox"] = "Virindi Paradox",
+ ["portalspace"] = "Portal Space Exploration"
+ };
+
+ ///
+ /// Get friendly name for a quest stamp, with fallback to original stamp
+ ///
+ /// The quest stamp to lookup
+ /// Friendly name if found, otherwise the original quest stamp
+ public static string GetFriendlyName(string questStamp)
+ {
+ if (string.IsNullOrEmpty(questStamp))
+ return questStamp;
+
+ return QuestStampToName.TryGetValue(questStamp.ToLower(), out string friendlyName)
+ ? friendlyName
+ : questStamp;
+ }
+
+ ///
+ /// Get display name with friendly name and original stamp in parentheses
+ ///
+ /// The quest stamp to format
+ /// Formatted display name
+ public static string GetDisplayName(string questStamp)
+ {
+ if (string.IsNullOrEmpty(questStamp))
+ return questStamp;
+
+ string friendlyName = GetFriendlyName(questStamp);
+
+ // If we found a mapping, show friendly name with original in parentheses
+ if (!string.Equals(friendlyName, questStamp, System.StringComparison.OrdinalIgnoreCase))
+ {
+ return $"{friendlyName} ({questStamp})";
+ }
+
+ // Otherwise just show the original
+ return questStamp;
+ }
+ }
+}
\ No newline at end of file
diff --git a/MosswartMassacre/QuestStreamingService.cs b/MosswartMassacre/QuestStreamingService.cs
new file mode 100644
index 0000000..5409d35
--- /dev/null
+++ b/MosswartMassacre/QuestStreamingService.cs
@@ -0,0 +1,133 @@
+using System;
+using System.Linq;
+using System.Timers;
+
+namespace MosswartMassacre
+{
+ ///
+ /// Streams high-priority quest timer data via WebSocket on a 30-second interval.
+ ///
+ internal class QuestStreamingService
+ {
+ private readonly IPluginLogger _logger;
+ private Timer _timer;
+
+ internal QuestStreamingService(IPluginLogger logger)
+ {
+ _logger = logger;
+ }
+
+ internal void Start()
+ {
+ _timer = new Timer(Constants.QuestStreamingIntervalMs);
+ _timer.Elapsed += OnTimerElapsed;
+ _timer.AutoReset = true;
+ _timer.Start();
+ }
+
+ internal void Stop()
+ {
+ if (_timer != null)
+ {
+ _timer.Stop();
+ _timer.Elapsed -= OnTimerElapsed;
+ _timer.Dispose();
+ _timer = null;
+ }
+ }
+
+ internal bool IsRunning => _timer != null && _timer.Enabled;
+
+ private void OnTimerElapsed(object sender, ElapsedEventArgs e)
+ {
+ try
+ {
+ if (PluginSettings.Instance?.VerboseLogging == true)
+ {
+ _logger?.Log("[QUEST-STREAM] Timer fired, checking conditions...");
+ }
+
+ if (!PluginCore.WebSocketEnabled)
+ {
+ if (PluginSettings.Instance?.VerboseLogging == true)
+ {
+ _logger?.Log("[QUEST-STREAM] WebSocket not enabled, skipping");
+ }
+ return;
+ }
+
+ var questManager = PluginCore.questManager;
+ if (questManager?.QuestList == null || questManager.QuestList.Count == 0)
+ {
+ if (PluginSettings.Instance?.VerboseLogging == true)
+ {
+ _logger?.Log($"[QUEST-STREAM] No quest data available (null: {questManager?.QuestList == null}, count: {questManager?.QuestList?.Count ?? 0})");
+ }
+ return;
+ }
+
+ var currentTime = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
+
+ var priorityQuests = questManager.QuestList
+ .Where(q => IsHighPriorityQuest(q.Id))
+ .GroupBy(q => q.Id)
+ .Select(g => g.First())
+ .ToList();
+
+ if (PluginSettings.Instance?.VerboseLogging == true)
+ {
+ _logger?.Log($"[QUEST-STREAM] Found {priorityQuests.Count} priority quests to stream");
+ }
+
+ foreach (var quest in priorityQuests)
+ {
+ try
+ {
+ string questName = questManager.GetFriendlyQuestName(quest.Id);
+ long timeRemaining = quest.ExpireTime - currentTime;
+ string countdown = FormatCountdown(timeRemaining);
+
+ if (PluginSettings.Instance?.VerboseLogging == true)
+ {
+ _logger?.Log($"[QUEST-STREAM] Sending: {questName} - {countdown}");
+ }
+
+ System.Threading.Tasks.Task.Run(() => WebSocket.SendQuestDataAsync(questName, countdown));
+ }
+ catch (Exception ex)
+ {
+ _logger?.Log($"[QUEST-STREAM] Error streaming quest {quest.Id}: {ex.Message}");
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger?.Log($"[QUEST-STREAM] Error in timer handler: {ex.Message}");
+ }
+ }
+
+ internal static bool IsHighPriorityQuest(string questId)
+ {
+ return questId == "stipendtimer_0812" ||
+ questId == "augmentationblankgemacquired" ||
+ questId == "insatiableeaterjaw";
+ }
+
+ internal static string FormatCountdown(long seconds)
+ {
+ if (seconds <= 0)
+ return "READY";
+
+ var timeSpan = TimeSpan.FromSeconds(seconds);
+
+ if (timeSpan.TotalDays >= 1)
+ return $"{(int)timeSpan.TotalDays}d {timeSpan.Hours:D2}h";
+ else if (timeSpan.TotalHours >= 1)
+ return $"{timeSpan.Hours}h {timeSpan.Minutes:D2}m";
+ else if (timeSpan.TotalMinutes >= 1)
+ return $"{timeSpan.Minutes}m {timeSpan.Seconds:D2}s";
+ else
+ return $"{timeSpan.Seconds}s";
+ }
+ }
+}
diff --git a/MosswartMassacre/RareTracker.cs b/MosswartMassacre/RareTracker.cs
new file mode 100644
index 0000000..5bd7c9d
--- /dev/null
+++ b/MosswartMassacre/RareTracker.cs
@@ -0,0 +1,71 @@
+using System;
+using System.Collections.Generic;
+using System.Text.RegularExpressions;
+using Decal.Adapter;
+
+namespace MosswartMassacre
+{
+ ///
+ /// Tracks rare item discoveries, handles rare meta state toggles,
+ /// and sends rare notifications via WebSocket.
+ ///
+ internal class RareTracker
+ {
+ private readonly IPluginLogger _logger;
+ private readonly string _characterName;
+
+ internal int RareCount { get; set; }
+ internal bool RareMetaEnabled { get; set; } = true;
+
+ internal RareTracker(IPluginLogger logger)
+ {
+ _logger = logger;
+ _characterName = CoreManager.Current.CharacterFilter.Name;
+ }
+
+ ///
+ /// Check if the chat text is a rare discovery by this character.
+ /// If so, increments count, triggers meta switch, allegiance announce, and WebSocket notification.
+ /// Returns true if a rare was found.
+ ///
+ internal bool CheckForRare(string text, out string rareText)
+ {
+ if (IsRareDiscoveryMessage(text, out rareText))
+ {
+ RareCount++;
+
+ if (RareMetaEnabled)
+ {
+ PluginCore.Decal_DispatchOnChatCommand("/vt setmetastate loot_rare");
+ }
+
+ DelayedCommandManager.AddDelayedCommand($"/a {rareText}", 3000);
+ _ = WebSocket.SendRareAsync(rareText);
+ return true;
+ }
+ return false;
+ }
+
+ internal void ToggleRareMeta()
+ {
+ PluginSettings.Instance.RareMetaEnabled = !PluginSettings.Instance.RareMetaEnabled;
+ RareMetaEnabled = PluginSettings.Instance.RareMetaEnabled;
+ }
+
+ private bool IsRareDiscoveryMessage(string text, out string rareTextOnly)
+ {
+ rareTextOnly = null;
+
+ string pattern = @"^(?['A-Za-z ]+)\shas discovered the (?- .*?)!$";
+ Match match = Regex.Match(text, pattern);
+
+ if (match.Success && match.Groups["name"].Value == _characterName)
+ {
+ rareTextOnly = match.Groups["item"].Value;
+ return true;
+ }
+
+ return false;
+ }
+ }
+}
diff --git a/MosswartMassacre/SpellManager.cs b/MosswartMassacre/SpellManager.cs
new file mode 100644
index 0000000..5d3732b
--- /dev/null
+++ b/MosswartMassacre/SpellManager.cs
@@ -0,0 +1,227 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Reflection;
+using Mag.Shared.Spells;
+
+namespace MosswartMassacre
+{
+ ///
+ /// Manages spell identification and cantrip detection for the Flag Tracker
+ ///
+ public static class SpellManager
+ {
+ private static readonly Dictionary SpellsById = new Dictionary();
+ private static readonly List SpellData = new List();
+ private static bool isInitialized = false;
+
+ static SpellManager()
+ {
+ Initialize();
+ }
+
+ private static void Initialize()
+ {
+ if (isInitialized) return;
+
+ try
+ {
+ // Load spell data from embedded CSV resource
+ var assembly = Assembly.GetExecutingAssembly();
+
+ // Try to find the resource with different naming patterns
+ var availableResources = assembly.GetManifestResourceNames();
+ var spellResource = availableResources.FirstOrDefault(r => r.Contains("Spells.csv"));
+
+ if (string.IsNullOrEmpty(spellResource))
+ {
+ // If not embedded, try to load from file system
+ var csvPath = Path.Combine(Path.GetDirectoryName(assembly.Location), "..", "Shared", "Spells", "Spells.csv");
+ if (File.Exists(csvPath))
+ {
+ LoadFromFile(csvPath);
+ isInitialized = true;
+ return;
+ }
+ }
+ else
+ {
+ using (var stream = assembly.GetManifestResourceStream(spellResource))
+ {
+ if (stream != null)
+ {
+ using (var reader = new StreamReader(stream))
+ {
+ LoadFromReader(reader);
+ }
+ }
+ }
+ }
+
+ isInitialized = true;
+ }
+ catch (Exception ex)
+ {
+ PluginCore.WriteToChat($"SpellManager initialization error: {ex.Message}");
+ }
+ }
+
+ private static void LoadFromFile(string path)
+ {
+ using (var reader = new StreamReader(path))
+ {
+ LoadFromReader(reader);
+ }
+ }
+
+ private static void LoadFromReader(StreamReader reader)
+ {
+ // Skip header line
+ var header = reader.ReadLine();
+
+ while (!reader.EndOfStream)
+ {
+ var line = reader.ReadLine();
+ if (!string.IsNullOrWhiteSpace(line))
+ {
+ var parts = line.Split(',');
+ if (parts.Length >= 6) // Minimum required fields
+ {
+ SpellData.Add(parts);
+
+ // Parse spell data
+ if (int.TryParse(parts[0], out int id))
+ {
+ var name = parts[1];
+ int.TryParse(parts[3], out int difficulty);
+ int.TryParse(parts[4], out int duration);
+ int.TryParse(parts[5], out int family);
+
+ var spell = new Spell(id, name, difficulty, duration, family);
+ SpellsById[id] = spell;
+ }
+ }
+ }
+ }
+ }
+
+ ///
+ /// Gets a spell by its ID
+ ///
+ public static Spell GetSpell(int id)
+ {
+ if (SpellsById.TryGetValue(id, out var spell))
+ return spell;
+ return null;
+ }
+
+ ///
+ /// Gets a spell by its name (case-insensitive)
+ ///
+ public static Spell GetSpell(string name)
+ {
+ return SpellsById.Values.FirstOrDefault(s =>
+ string.Equals(s.Name, name, StringComparison.OrdinalIgnoreCase));
+ }
+
+ ///
+ /// Gets the total number of spells loaded
+ ///
+ public static int GetSpellCount()
+ {
+ return SpellsById.Count;
+ }
+
+ ///
+ /// Detects if a spell is a cantrip and returns its info
+ ///
+ public static CantripInfo DetectCantrip(Spell spell)
+ {
+ if (spell == null || spell.CantripLevel == Spell.CantripLevels.None)
+ return null;
+
+ var info = new CantripInfo
+ {
+ SpellId = spell.Id,
+ Name = spell.Name,
+ Level = GetCantripLevelName(spell.CantripLevel),
+ Color = GetCantripColor(spell.CantripLevel)
+ };
+
+ // Extract skill/attribute name from spell name
+ info.SkillName = ExtractSkillFromSpellName(spell.Name, info.Level);
+
+ return info;
+ }
+
+ private static string GetCantripLevelName(Spell.CantripLevels level)
+ {
+ switch (level)
+ {
+ case Spell.CantripLevels.Minor: return "Minor";
+ case Spell.CantripLevels.Moderate: return "Moderate";
+ case Spell.CantripLevels.Major: return "Major";
+ case Spell.CantripLevels.Epic: return "Epic";
+ case Spell.CantripLevels.Legendary: return "Legendary";
+ default: return "N/A";
+ }
+ }
+
+ private static System.Drawing.Color GetCantripColor(Spell.CantripLevels level)
+ {
+ switch (level)
+ {
+ case Spell.CantripLevels.Minor: return System.Drawing.Color.White;
+ case Spell.CantripLevels.Moderate: return System.Drawing.Color.Green;
+ case Spell.CantripLevels.Major: return System.Drawing.Color.Blue;
+ case Spell.CantripLevels.Epic: return System.Drawing.Color.Purple;
+ case Spell.CantripLevels.Legendary: return System.Drawing.Color.Orange;
+ default: return System.Drawing.Color.White;
+ }
+ }
+
+ private static string ExtractSkillFromSpellName(string spellName, string level)
+ {
+ // Remove the cantrip level prefix
+ var skillPart = spellName;
+ if (!string.IsNullOrEmpty(level) && skillPart.StartsWith(level + " "))
+ {
+ skillPart = skillPart.Substring(level.Length + 1);
+ }
+
+ // Map common spell name patterns to skill names
+ if (skillPart.Contains("Strength")) return "Strength";
+ if (skillPart.Contains("Endurance")) return "Endurance";
+ if (skillPart.Contains("Coordination")) return "Coordination";
+ if (skillPart.Contains("Quickness")) return "Quickness";
+ if (skillPart.Contains("Focus")) return "Focus";
+ if (skillPart.Contains("Self") || skillPart.Contains("Willpower")) return "Willpower";
+
+ // Protection mappings
+ if (skillPart.Contains("Armor")) return "Armor";
+ if (skillPart.Contains("Bludgeoning")) return "Bludgeoning Ward";
+ if (skillPart.Contains("Piercing")) return "Piercing Ward";
+ if (skillPart.Contains("Slashing")) return "Slashing Ward";
+ if (skillPart.Contains("Flame") || skillPart.Contains("Fire")) return "Flame Ward";
+ if (skillPart.Contains("Frost") || skillPart.Contains("Cold")) return "Frost Ward";
+ if (skillPart.Contains("Acid")) return "Acid Ward";
+ if (skillPart.Contains("Lightning") || skillPart.Contains("Electric")) return "Storm Ward";
+
+ // Return the skill part as-is if no mapping found
+ return skillPart;
+ }
+
+ ///
+ /// Information about a detected cantrip
+ ///
+ public class CantripInfo
+ {
+ public int SpellId { get; set; }
+ public string Name { get; set; }
+ public string SkillName { get; set; }
+ public string Level { get; set; }
+ public System.Drawing.Color Color { get; set; }
+ }
+ }
+}
\ No newline at end of file
diff --git a/MosswartMassacre/Telemetry.cs b/MosswartMassacre/Telemetry.cs
deleted file mode 100644
index ce9bb79..0000000
--- a/MosswartMassacre/Telemetry.cs
+++ /dev/null
@@ -1,109 +0,0 @@
-// Telemetry.cs ───────────────────────────────────────────────────────────────
-using System;
-using System.Net.Http;
-using System.Text;
-using System.Threading;
-using System.Threading.Tasks;
-using Decal.Adapter;
-using Newtonsoft.Json;
-
-namespace MosswartMassacre
-{
- public static class Telemetry
- {
- /* ───────────── configuration ───────────── */
- private const string Endpoint = "https://mosswart.snakedesert.se/position/"; // <- trailing slash!
- private const string SharedSecret = "your_shared_secret"; // <- keep in sync
- private const int IntervalSec = 5; // seconds between posts
-
- /* ───────────── runtime state ───────────── */
- private static readonly HttpClient _http = new HttpClient();
- private static string _sessionId;
- private static CancellationTokenSource _cts;
- private static bool _enabled;
-
- /* ───────────── public API ───────────── */
- public static void Start()
- {
- if (_enabled) return;
-
- _enabled = true;
- _sessionId = $"{CoreManager.Current.CharacterFilter.Name}-{DateTime.UtcNow:yyyyMMdd-HHmmss}";
- _cts = new CancellationTokenSource();
-
- PluginCore.WriteToChat("[Telemetry] HTTP streaming ENABLED");
-
- _ = Task.Run(() => LoopAsync(_cts.Token)); // fire-and-forget
- }
-
- public static void Stop()
- {
- if (!_enabled) return;
- _cts.Cancel();
- _enabled = false;
- PluginCore.WriteToChat("[Telemetry] HTTP streaming DISABLED");
- }
-
- /* ───────────── async loop ───────────── */
- private static async Task LoopAsync(CancellationToken token)
- {
- while (!token.IsCancellationRequested)
- {
- try
- {
- await SendSnapshotAsync(token);
- }
- catch (Exception ex)
- {
- PluginCore.WriteToChat($"[Telemetry] send failed: {ex.Message}");
- }
-
- try
- {
- await Task.Delay(TimeSpan.FromSeconds(IntervalSec), token);
- }
- catch (TaskCanceledException) { } // expected on Stop()
- }
- }
-
- /* ───────────── single POST ───────────── */
- private static async Task SendSnapshotAsync(CancellationToken token)
- {
- var coords = Coordinates.Me;
-
- var payload = new
- {
- character_name = CoreManager.Current.CharacterFilter.Name,
- char_tag = PluginCore.CharTag,
- session_id = _sessionId,
- timestamp = DateTime.UtcNow.ToString("o"),
-
- ew = coords.EW,
- ns = coords.NS,
- z = coords.Z,
-
- kills = PluginCore.totalKills,
- onlinetime = (DateTime.Now - PluginCore.statsStartTime).ToString(@"dd\.hh\:mm\:ss"),
- kills_per_hour = PluginCore.killsPerHour.ToString("F0"),
- deaths = 0,
- rares_found = PluginCore.rareCount,
- prismatic_taper_count = 0,
- vt_state = VtankControl.VtGetMetaState(),
- };
-
- string json = JsonConvert.SerializeObject(payload);
- var req = new HttpRequestMessage(HttpMethod.Post, Endpoint)
- {
- Content = new StringContent(json, Encoding.UTF8, "application/json")
- };
- req.Headers.Add("X-Plugin-Secret", SharedSecret);
-
- using var resp = await _http.SendAsync(req, token);
-
- if (!resp.IsSuccessStatusCode) // stay quiet on success
- {
- PluginCore.WriteToChat($"[Telemetry] server replied {resp.StatusCode}");
- }
- }
- }
-}
diff --git a/MosswartMassacre/UpdateManager.cs b/MosswartMassacre/UpdateManager.cs
new file mode 100644
index 0000000..6eaa70c
--- /dev/null
+++ b/MosswartMassacre/UpdateManager.cs
@@ -0,0 +1,253 @@
+using System;
+using System.IO;
+using System.Net.Http;
+using System.Security.Cryptography;
+using System.Threading.Tasks;
+
+namespace MosswartMassacre
+{
+ public static class UpdateManager
+ {
+ private const string UPDATE_URL = "https://git.snakedesert.se/SawatoMosswartsEnjoyersClub/MosswartMassacre/raw/branch/spawn-detection/MosswartMassacre/bin/Release/MosswartMassacre.dll";
+
+ private static bool updateAvailable = false;
+ private static string remoteFileHash = string.Empty;
+ private static string localFileHash = string.Empty;
+ private static DateTime lastCheckTime = DateTime.MinValue;
+
+ public static bool IsUpdateAvailable => updateAvailable;
+ public static DateTime LastCheckTime => lastCheckTime;
+
+ ///
+ /// Calculate SHA256 hash of a file
+ ///
+ private static string CalculateFileHash(string filePath)
+ {
+ using (var sha256 = SHA256.Create())
+ {
+ using (var stream = File.OpenRead(filePath))
+ {
+ byte[] hashBytes = sha256.ComputeHash(stream);
+ return BitConverter.ToString(hashBytes).Replace("-", "").ToLowerInvariant();
+ }
+ }
+ }
+
+ ///
+ /// Calculate SHA256 hash of byte array
+ ///
+ private static string CalculateHash(byte[] data)
+ {
+ using (var sha256 = SHA256.Create())
+ {
+ byte[] hashBytes = sha256.ComputeHash(data);
+ return BitConverter.ToString(hashBytes).Replace("-", "").ToLowerInvariant();
+ }
+ }
+
+ public static async Task CheckForUpdateAsync()
+ {
+ try
+ {
+ PluginCore.WriteToChat("[Update] Checking for updates...");
+
+ // Get local file hash
+ string localPath = GetLocalDllPath();
+ if (!File.Exists(localPath))
+ {
+ PluginCore.WriteToChat("[Update] Error: Could not find local DLL file");
+ return false;
+ }
+
+ PluginCore.WriteToChat("[Update] Calculating local file hash...");
+ localFileHash = CalculateFileHash(localPath);
+
+ // Download remote file and calculate hash
+ using (var client = new HttpClient())
+ {
+ client.Timeout = TimeSpan.FromSeconds(30);
+
+ PluginCore.WriteToChat("[Update] Downloading remote file for comparison...");
+ var remoteData = await client.GetByteArrayAsync(UPDATE_URL);
+
+ if (remoteData == null || remoteData.Length == 0)
+ {
+ PluginCore.WriteToChat("[Update] Error: Could not download remote file");
+ return false;
+ }
+
+ PluginCore.WriteToChat("[Update] Calculating remote file hash...");
+ remoteFileHash = CalculateHash(remoteData);
+ }
+
+ // Compare hashes
+ updateAvailable = !string.Equals(localFileHash, remoteFileHash, StringComparison.OrdinalIgnoreCase);
+ lastCheckTime = DateTime.Now;
+
+ if (updateAvailable)
+ {
+ PluginCore.WriteToChat($"[Update] Update available!");
+ PluginCore.WriteToChat($"[Update] Local hash: {localFileHash}");
+ PluginCore.WriteToChat($"[Update] Remote hash: {remoteFileHash}");
+ }
+ else
+ {
+ PluginCore.WriteToChat("[Update] Up to date - hashes match");
+ }
+
+ return true;
+ }
+ catch (HttpRequestException ex)
+ {
+ PluginCore.WriteToChat($"[Update] Network error: {ex.Message}");
+ return false;
+ }
+ catch (TaskCanceledException)
+ {
+ PluginCore.WriteToChat("[Update] Request timed out");
+ return false;
+ }
+ catch (Exception ex)
+ {
+ PluginCore.WriteToChat($"[Update] Check failed: {ex.Message}");
+ return false;
+ }
+ }
+
+ public static async Task DownloadAndInstallUpdateAsync()
+ {
+ if (!updateAvailable)
+ {
+ PluginCore.WriteToChat("[Update] No update available. Run /mm checkforupdate first.");
+ return false;
+ }
+
+ try
+ {
+ PluginCore.WriteToChat("[Update] Downloading update...");
+
+ string localPath = GetLocalDllPath();
+ string tempPath = localPath + ".tmp";
+ string backupPath = localPath + ".bak";
+
+ // Download to temp file
+ using (var client = new HttpClient())
+ {
+ client.Timeout = TimeSpan.FromSeconds(30);
+
+ var response = await client.GetAsync(UPDATE_URL);
+ response.EnsureSuccessStatusCode();
+
+ using (var fileStream = File.Create(tempPath))
+ {
+ await response.Content.CopyToAsync(fileStream);
+ }
+ }
+
+ // Validate downloaded file by hash
+ PluginCore.WriteToChat("[Update] Validating downloaded file...");
+ var downloadedHash = CalculateFileHash(tempPath);
+ if (!string.Equals(downloadedHash, remoteFileHash, StringComparison.OrdinalIgnoreCase))
+ {
+ File.Delete(tempPath);
+ PluginCore.WriteToChat($"[Update] Download validation failed. Hash mismatch!");
+ PluginCore.WriteToChat($"[Update] Expected: {remoteFileHash}");
+ PluginCore.WriteToChat($"[Update] Got: {downloadedHash}");
+ return false;
+ }
+
+ PluginCore.WriteToChat("[Update] Download complete, installing...");
+
+ // Atomically replace current file with new version (creates backup automatically)
+ File.Replace(tempPath, localPath, backupPath);
+
+ // Clear update flag
+ updateAvailable = false;
+
+ PluginCore.WriteToChat("[Update] Update installed successfully!");
+ PluginCore.WriteToChat("[Update] Previous version backed up as MosswartMassacre.dll.bak");
+
+ // Wait a moment for file system to settle, then trigger hot reload
+ await System.Threading.Tasks.Task.Delay(1000);
+
+ try
+ {
+ // Touch the file to ensure FileSystemWatcher detects the change
+ File.SetLastWriteTime(localPath, DateTime.Now);
+ PluginCore.WriteToChat("[Update] Triggering hot reload...");
+ }
+ catch (Exception ex)
+ {
+ PluginCore.WriteToChat($"[Update] Could not trigger hot reload: {ex.Message}");
+ PluginCore.WriteToChat("[Update] Please use /mm gui to reload manually");
+ }
+
+ return true;
+ }
+ catch (HttpRequestException ex)
+ {
+ PluginCore.WriteToChat($"[Update] Download error: {ex.Message}");
+ return false;
+ }
+ catch (TaskCanceledException)
+ {
+ PluginCore.WriteToChat("[Update] Download timed out");
+ return false;
+ }
+ catch (UnauthorizedAccessException)
+ {
+ PluginCore.WriteToChat("[Update] File access denied. Make sure the plugin directory is writable.");
+ return false;
+ }
+ catch (Exception ex)
+ {
+ PluginCore.WriteToChat($"[Update] Install failed: {ex.Message}");
+ return false;
+ }
+ }
+
+ ///
+ /// Check for update and auto-install if available. Used by startup auto-update.
+ ///
+ public static async Task CheckAndInstallAsync()
+ {
+ try
+ {
+ bool checkOk = await CheckForUpdateAsync();
+ if (checkOk && updateAvailable)
+ {
+ PluginCore.WriteToChat("[Update] Auto-installing update...");
+ await DownloadAndInstallUpdateAsync();
+ }
+ }
+ catch (Exception ex)
+ {
+ PluginCore.WriteToChat($"[Update] Auto-update failed: {ex.Message}");
+ }
+ }
+
+ private static string GetLocalDllPath()
+ {
+ // Get the path to the current DLL
+ string assemblyPath = typeof(PluginCore).Assembly.Location;
+
+ // If empty (hot reload scenario), use AssemblyDirectory + filename
+ if (string.IsNullOrEmpty(assemblyPath))
+ {
+ return Path.Combine(PluginCore.AssemblyDirectory, "MosswartMassacre.dll");
+ }
+
+ return assemblyPath;
+ }
+
+ public static string GetUpdateStatus()
+ {
+ if (lastCheckTime == DateTime.MinValue)
+ {
+ return "Update Status: Not checked";
+ }
+
+ return updateAvailable ? "Update Status: Update available" : "Update Status: Up to date";
+ }
+ }
+}
\ No newline at end of file
diff --git a/MosswartMassacre/Utils.cs b/MosswartMassacre/Utils.cs
index 5a63a12..c6a7381 100644
--- a/MosswartMassacre/Utils.cs
+++ b/MosswartMassacre/Utils.cs
@@ -2,6 +2,8 @@
using Decal.Adapter;
using Decal.Adapter.Wrappers;
using System.Numerics;
+using Mag.Shared.Constants;
+using System.Linq;
namespace MosswartMassacre
{
@@ -39,6 +41,29 @@ namespace MosswartMassacre
}
}
+ ///
+ /// Return any WorldObject's raw world position by reading the
+ /// physics-object pointer (same offsets: +0x84/88/8C).
+ ///
+ public static unsafe Vector3 GetWorldObjectPosition(int objectId)
+ {
+ try
+ {
+ if (!CoreManager.Current.Actions.IsValidObject(objectId))
+ return new Vector3();
+
+ byte* p = (byte*)CoreManager.Current.Actions.Underlying.GetPhysicsObjectPtr(objectId);
+ return new Vector3(
+ *(float*)(p + 0x84), // X
+ *(float*)(p + 0x88), // Y
+ *(float*)(p + 0x8C)); // Z
+ }
+ catch
+ {
+ return new Vector3();
+ }
+ }
+
///
/// Convenience: returns the current landcell (upper 16 bits of landblock).
///
@@ -63,6 +88,26 @@ namespace MosswartMassacre
return new Coordinates(ew, ns, pos.Z);
}
+ ///
+ /// Get AC-style coordinates (EW/NS/Z) for any WorldObject.
+ ///
+ public static Coordinates GetWorldObjectCoordinates(WorldObject wo)
+ {
+ if (wo == null) return new Coordinates();
+
+ Vector3 pos = GetWorldObjectPosition(wo.Id);
+
+ // Get landcell from the object's coordinates
+ var coordsObj = wo.Coordinates();
+ if (coordsObj == null) return new Coordinates();
+
+ // Convert DECAL coords to our Coordinates with Z
+ double ew = coordsObj.EastWest;
+ double ns = coordsObj.NorthSouth;
+
+ return new Coordinates(ew, ns, pos.Z);
+ }
+
/* ----------------------------------------------------------
* 3) Generic math helpers you may want later
* -------------------------------------------------------- */
@@ -72,5 +117,212 @@ namespace MosswartMassacre
public static double DegToRad(double deg) => deg * Math.PI / 180.0;
public static double RadToDeg(double rad) => rad * 180.0 / Math.PI;
+
+ /* ----------------------------------------------------------
+ * 4) Generic item property access
+ * -------------------------------------------------------- */
+
+ ///
+ /// Find a WorldObject item by name in inventory
+ ///
+ /// Name of the item to find
+ /// WorldObject or null if not found
+ public static WorldObject FindItemByName(string itemName)
+ {
+ try
+ {
+ //var worldFilter = CoreManager.Current.WorldFilter;
+ //var playerInv = CoreManager.Current.CharacterFilter.Id;
+
+ // Search inventory
+
+ foreach (WorldObject wo in CoreManager.Current.WorldFilter.GetInventory())
+ {
+ if (string.Equals(wo.Name, itemName, StringComparison.OrdinalIgnoreCase))
+ return wo;
+ }
+
+ return null;
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ ///
+ /// Get the stack size/quantity of a specific item by name
+ ///
+ /// Name of the item to find
+ /// Stack size or 0 if not found
+ ///
+ /// Return the total quantity of an item in the character’s inventory,
+ /// adding up every stack that shares .
+ ///
+ public static int GetItemStackSize(string itemName)
+ {
+ try
+ {
+ // 1. Pull every WorldObject in bags + containers
+ var inv = CoreManager.Current.WorldFilter.GetInventory();
+
+ // 2. Keep only those whose display name matches (case-insensitive)
+ // 3. For each one, use StackCount if it exists, otherwise treat as 1
+ return inv.Where(wo =>
+ string.Equals(wo.Name, itemName,
+ StringComparison.OrdinalIgnoreCase))
+ .Sum(wo =>
+ {
+ // Some items (weapons, armor) aren’t stackable;
+ // Values(LongValueKey.StackCount) throws if the key is absent.
+ try
+ {
+ return wo.Values(LongValueKey.StackCount);
+ }
+ catch
+ {
+ return 1; // non-stackable item = quantity 1
+ }
+ });
+ }
+ catch
+ {
+ return 0;
+ }
+ }
+
+
+ ///
+ /// Get the icon ID of a specific item by name
+ ///
+ /// Name of the item to find
+ /// Icon ID or 0 if not found
+ public static int GetItemIcon(string itemName)
+ {
+ try
+ {
+ var item = FindItemByName(itemName);
+ return item?.Icon ?? 0;
+ }
+ catch
+ {
+ return 0;
+ }
+ }
+
+ ///
+ /// Get the display icon ID (with 0x6000000 offset) for an item by name
+ ///
+ /// Name of the item to find
+ /// Display icon ID or 0x6002D14 (default icon) if not found
+ public static int GetItemDisplayIcon(string itemName)
+ {
+ int rawIcon = GetItemIcon(itemName);
+ return rawIcon != 0 ? rawIcon + 0x6000000 : 0x6002D14;
+ }
+
+ /* ----------------------------------------------------------
+ * 5) Chest Looter helper methods
+ * -------------------------------------------------------- */
+
+ ///
+ /// Calculate 3D distance from player to a world object
+ ///
+ /// World object ID
+ /// Distance in meters, or float.MaxValue if object is invalid
+ public static float GetDistanceToWorldObject(int objectId)
+ {
+ try
+ {
+ if (!CoreManager.Current.Actions.IsValidObject(objectId))
+ return float.MaxValue;
+
+ Vector3 playerPos = GetPlayerPosition();
+ Vector3 objectPos = GetWorldObjectPosition(objectId);
+
+ return Vector3.Distance(playerPos, objectPos);
+ }
+ catch
+ {
+ return float.MaxValue;
+ }
+ }
+
+ ///
+ /// Find the closest chest with the specified name in the game world
+ ///
+ /// Name of the chest to find
+ /// WorldObject of the closest chest, or null if not found
+ public static WorldObject FindClosestChestByName(string chestName)
+ {
+ try
+ {
+ WorldObject closestChest = null;
+ float closestDistance = float.MaxValue;
+
+ // Search all objects in WorldFilter
+ using (var objects = CoreManager.Current.WorldFilter.GetAll())
+ {
+ foreach (WorldObject wo in objects)
+ {
+ // Check if this is a container (chest)
+ if (wo.ObjectClass != ObjectClass.Container)
+ continue;
+
+ // Check if name matches (case-insensitive, partial match allowed)
+ if (!wo.Name.Contains(chestName) &&
+ !string.Equals(wo.Name, chestName, StringComparison.OrdinalIgnoreCase))
+ continue;
+
+ // Calculate distance
+ float distance = GetDistanceToWorldObject(wo.Id);
+
+ // Update closest if this is nearer
+ if (distance < closestDistance)
+ {
+ closestDistance = distance;
+ closestChest = wo;
+ }
+ }
+ }
+
+ return closestChest;
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ ///
+ /// Find a key in the player's inventory by name
+ ///
+ /// Name of the key to find
+ /// WorldObject of the key, or null if not found
+ public static WorldObject FindKeyInInventory(string keyName)
+ {
+ try
+ {
+ foreach (WorldObject wo in CoreManager.Current.WorldFilter.GetInventory())
+ {
+ // Check if this is a key
+ if (wo.ObjectClass != ObjectClass.Key)
+ continue;
+
+ // Check if name matches (case-insensitive, partial match allowed)
+ if (wo.Name.Contains(keyName) ||
+ string.Equals(wo.Name, keyName, StringComparison.OrdinalIgnoreCase))
+ {
+ return wo;
+ }
+ }
+
+ return null;
+ }
+ catch
+ {
+ return null;
+ }
+ }
}
}
diff --git a/MosswartMassacre/ViewSystemSelector.cs b/MosswartMassacre/ViewSystemSelector.cs
deleted file mode 100644
index 5e75b7a..0000000
--- a/MosswartMassacre/ViewSystemSelector.cs
+++ /dev/null
@@ -1,262 +0,0 @@
-///////////////////////////////////////////////////////////////////////////////
-//File: ViewSystemSelector.cs
-//
-//Description: Contains the MyClasses.MetaViewWrappers.ViewSystemSelector class,
-// which is used to determine whether the Virindi View Service is enabled.
-// As with all the VVS wrappers, the VVS_REFERENCED compilation symbol must be
-// defined for the VVS code to be compiled. Otherwise, only Decal views are used.
-//
-//References required:
-// VirindiViewService (if VVS_REFERENCED is defined)
-// Decal.Adapter
-// Decal.Interop.Core
-//
-//This file is Copyright (c) 2009 VirindiPlugins
-//
-//Permission is hereby granted, free of charge, to any person obtaining a copy
-// of this software and associated documentation files (the "Software"), to deal
-// in the Software without restriction, including without limitation the rights
-// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-// copies of the Software, and to permit persons to whom the Software is
-// furnished to do so, subject to the following conditions:
-//
-//The above copyright notice and this permission notice shall be included in
-// all copies or substantial portions of the Software.
-//
-//THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-// THE SOFTWARE.
-///////////////////////////////////////////////////////////////////////////////
-
-using System;
-using System.Collections.Generic;
-using System.Text;
-using System.Reflection;
-
-#if METAVIEW_PUBLIC_NS
-namespace MetaViewWrappers
-#else
-namespace MyClasses.MetaViewWrappers
-#endif
-{
- internal static class ViewSystemSelector
- {
- public enum eViewSystem
- {
- DecalInject,
- VirindiViewService,
- }
-
-
- ///////////////////////////////System presence detection///////////////////////////////
-
- public static bool IsPresent(Decal.Adapter.Wrappers.PluginHost pHost, eViewSystem VSystem)
- {
- switch (VSystem)
- {
- case eViewSystem.DecalInject:
- return true;
- case eViewSystem.VirindiViewService:
- return VirindiViewsPresent(pHost);
- default:
- return false;
- }
- }
- static bool VirindiViewsPresent(Decal.Adapter.Wrappers.PluginHost pHost)
- {
-#if VVS_REFERENCED
- System.Reflection.Assembly[] asms = AppDomain.CurrentDomain.GetAssemblies();
-
- foreach (System.Reflection.Assembly a in asms)
- {
- AssemblyName nmm = a.GetName();
- if ((nmm.Name == "VirindiViewService") && (nmm.Version >= new System.Version("1.0.0.37")))
- {
- try
- {
- return Curtain_VVS_Running();
- }
- catch
- {
- return false;
- }
- }
- }
-
- return false;
-#else
- return false;
-#endif
- }
- public static bool VirindiViewsPresent(Decal.Adapter.Wrappers.PluginHost pHost, Version minver)
- {
-#if VVS_REFERENCED
- System.Reflection.Assembly[] asms = AppDomain.CurrentDomain.GetAssemblies();
-
- foreach (System.Reflection.Assembly a in asms)
- {
- AssemblyName nm = a.GetName();
- if ((nm.Name == "VirindiViewService") && (nm.Version >= minver))
- {
- try
- {
- return Curtain_VVS_Running();
- }
- catch
- {
- return false;
- }
- }
- }
-
- return false;
-#else
- return false;
-#endif
- }
-
-#if VVS_REFERENCED
- static bool Curtain_VVS_Running()
- {
- return VirindiViewService.Service.Running;
- }
-#endif
-
- ///////////////////////////////CreateViewResource///////////////////////////////
-
- public static IView CreateViewResource(Decal.Adapter.Wrappers.PluginHost pHost, string pXMLResource)
- {
-#if VVS_REFERENCED
- if (IsPresent(pHost, eViewSystem.VirindiViewService))
- return CreateViewResource(pHost, pXMLResource, eViewSystem.VirindiViewService);
- else
-#endif
- return CreateViewResource(pHost, pXMLResource, eViewSystem.DecalInject);
- }
- public static IView CreateViewResource(Decal.Adapter.Wrappers.PluginHost pHost, string pXMLResource, eViewSystem VSystem)
- {
- if (!IsPresent(pHost, VSystem)) return null;
- switch (VSystem)
- {
- case eViewSystem.DecalInject:
- return CreateDecalViewResource(pHost, pXMLResource);
- case eViewSystem.VirindiViewService:
-#if VVS_REFERENCED
- return CreateMyHudViewResource(pHost, pXMLResource);
-#else
- break;
-#endif
- }
- return null;
- }
- static IView CreateDecalViewResource(Decal.Adapter.Wrappers.PluginHost pHost, string pXMLResource)
- {
- IView ret = new DecalControls.View();
- ret.Initialize(pHost, pXMLResource);
- return ret;
- }
-
-#if VVS_REFERENCED
- static IView CreateMyHudViewResource(Decal.Adapter.Wrappers.PluginHost pHost, string pXMLResource)
- {
- IView ret = new VirindiViewServiceHudControls.View();
- ret.Initialize(pHost, pXMLResource);
- return ret;
- }
-#endif
-
-
- ///////////////////////////////CreateViewXML///////////////////////////////
-
- public static IView CreateViewXML(Decal.Adapter.Wrappers.PluginHost pHost, string pXML)
- {
-#if VVS_REFERENCED
- if (IsPresent(pHost, eViewSystem.VirindiViewService))
- return CreateViewXML(pHost, pXML, eViewSystem.VirindiViewService);
- else
-#endif
- return CreateViewXML(pHost, pXML, eViewSystem.DecalInject);
- }
-
- public static IView CreateViewXML(Decal.Adapter.Wrappers.PluginHost pHost, string pXML, eViewSystem VSystem)
- {
- if (!IsPresent(pHost, VSystem)) return null;
- switch (VSystem)
- {
- case eViewSystem.DecalInject:
- return CreateDecalViewXML(pHost, pXML);
- case eViewSystem.VirindiViewService:
-#if VVS_REFERENCED
- return CreateMyHudViewXML(pHost, pXML);
-#else
- break;
-#endif
- }
- return null;
- }
- static IView CreateDecalViewXML(Decal.Adapter.Wrappers.PluginHost pHost, string pXML)
- {
- IView ret = new DecalControls.View();
- ret.InitializeRawXML(pHost, pXML);
- return ret;
- }
-
-#if VVS_REFERENCED
- static IView CreateMyHudViewXML(Decal.Adapter.Wrappers.PluginHost pHost, string pXML)
- {
- IView ret = new VirindiViewServiceHudControls.View();
- ret.InitializeRawXML(pHost, pXML);
- return ret;
- }
-#endif
-
-
- ///////////////////////////////HasChatOpen///////////////////////////////
-
- public static bool AnySystemHasChatOpen(Decal.Adapter.Wrappers.PluginHost pHost)
- {
- if (IsPresent(pHost, eViewSystem.VirindiViewService))
- if (HasChatOpen_VirindiViews()) return true;
- if (pHost.Actions.ChatState) return true;
- return false;
- }
-
- static bool HasChatOpen_VirindiViews()
- {
-#if VVS_REFERENCED
- if (VirindiViewService.HudView.FocusControl != null)
- {
- if (VirindiViewService.HudView.FocusControl.GetType() == typeof(VirindiViewService.Controls.HudTextBox))
- return true;
- }
- return false;
-#else
- return false;
-#endif
- }
-
- public delegate void delConditionalSplit(object data);
- public static void ViewConditionalSplit(IView v, delConditionalSplit onDecal, delConditionalSplit onVVS, object data)
- {
- Type vtype = v.GetType();
-
-#if VVS_REFERENCED
- if (vtype == typeof(VirindiViewServiceHudControls.View))
- {
- if (onVVS != null)
- onVVS(data);
- }
-#endif
-
- if (vtype == typeof(DecalControls.View))
- {
- if (onDecal != null)
- onDecal(data);
- }
- }
- }
-}
diff --git a/MosswartMassacre/ViewXML/flagTracker.xml b/MosswartMassacre/ViewXML/flagTracker.xml
new file mode 100644
index 0000000..565f9d6
--- /dev/null
+++ b/MosswartMassacre/ViewXML/flagTracker.xml
@@ -0,0 +1,74 @@
+
+
+
+
+
+