diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..9a095ed --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,127 @@ +# AGENTS.md + +Guidance for coding agents working in `MosswartMassacre` (DECAL plugin). + +See shared cross-repo guidance first: `../AGENTS.md`. + +## Scope and architecture + +- This repo is a C# plugin for Asheron's Call using DECAL + VirindiViewService. +- Main projects in solution `mossy.sln`: +- `MosswartMassacre/MosswartMassacre.csproj` (primary plugin) +- `MosswartMassacre.Loader/MosswartMassacre.Loader.csproj` (loader) +- Target framework is `.NET Framework 4.8` (`net48`), x86 runtime expectations. +- The plugin emits WebSocket events consumed by `MosswartOverlord`. + +## Rule sources discovered + +- Cursor rules: none found (`.cursor/rules/` missing, `.cursorrules` missing). +- Copilot rules: none found (`.github/copilot-instructions.md` missing). +- Follow cross-repo protocol rules in `../AGENTS.md` for payload compatibility. + +## Build commands + +## Solution build + +- Build Debug solution (recommended during development): +- `msbuild "mossy.sln" /p:Configuration=Debug /p:Platform="Any CPU"` +- Build Release solution: +- `msbuild "mossy.sln" /p:Configuration=Release /p:Platform="Any CPU"` + +## Project build + +- Build primary plugin project: +- `msbuild "MosswartMassacre/MosswartMassacre.csproj" /p:Configuration=Debug /p:Platform="AnyCPU"` +- Build loader project: +- `msbuild "MosswartMassacre.Loader/MosswartMassacre.Loader.csproj" /p:Configuration=Debug /p:Platform="AnyCPU"` + +## Restore dependencies + +- Legacy packages restore (when needed): +- `nuget restore "mossy.sln"` +- If package restore issues appear, verify `packages/` and `packages.config` state. + +## Lint/format/test status + +- No repository-wide C# linter/formatter config was found (`.editorconfig`, StyleCop, dotnet-format config not present). +- No automated unit/integration test project was found in this repo. +- Validation is primarily build + in-game runtime verification. + +## Single-test guidance (practical) + +- Because no automated test suite exists, "single test" means targeted manual verification. +- Preferred single-scenario checks: +- Toggle one plugin command and validate behavior (example: `/mm telemetry on`). +- Trigger one event path (example: spawn or chat) and verify backend receives it. +- For backend-coupled checks, also tail backend logs in `MosswartOverlord`. + +## Runtime/integration quick checks + +- Verify plugin loads in DECAL and opens the VVS tabbed UI. +- Confirm settings persist per character via YAML file. +- Confirm WebSocket registration and periodic telemetry emission. +- Confirm one inventory/full-inventory or delta message reaches backend. +- Confirm no repeated event handler subscriptions after hot reload. + +## Important coupling points + +- WebSocket endpoint currently defined in `MosswartMassacre/WebSocket.cs`. +- Shared secret header logic also in `MosswartMassacre/WebSocket.cs`. +- Event envelope fields and `type` values must stay backend-compatible. +- Avoid unilateral changes to JSON keys (`character_name`, coords, timestamps, etc.). + +## Code style conventions observed + +## Language and formatting + +- Use C# 8-compatible syntax; do not rely on newer language-only features. +- Use 4-space indentation and braces on new lines (existing style). +- Keep files UTF-8 and preserve existing BOM behavior where present. +- Keep methods focused; prefer extracting helpers over deeply nested blocks. + +## Imports/usings + +- Group `using` statements at top of file. +- Order with `System*` namespaces first, then third-party, then project namespaces. +- Remove unused `using` directives when touching a file. + +## Naming and structure + +- Public types/methods/properties: `PascalCase`. +- Local variables/private fields: `camelCase`; private static fields commonly `_prefixed`. +- Constants: `UPPER_SNAKE_CASE` or `PascalCase` according to existing file style. +- Keep event handler names descriptive (`OnX`, `HandleX`, `..._LoginComplete`). + +## Error handling and logging + +- Guard plugin startup/shutdown with robust exception handling. +- For external boundaries (WebSocket, file I/O, DECAL hooks), catch and log failures. +- Prefer non-crashing failure behavior with clear in-chat or logger diagnostics. +- Preserve existing logging patterns (`IPluginLogger`, plugin chat output) in touched files. + +## Concurrency and event safety + +- Be careful with timers, event subscriptions, and hot-reload lifecycle. +- Always unsubscribe handlers during cleanup to prevent duplicate callbacks. +- Avoid blocking calls on game/UI event threads. +- Keep thread-affinity concerns explicit when interacting with UI/game APIs. + +## Settings and persistence + +- Settings are YAML-backed and character-specific (`PluginSettings`). +- Preserve atomic save behavior (temp file + replace/move pattern). +- Add new settings as optional with safe defaults to keep backward compatibility. + +## Payload and API conventions + +- Emit JSON with stable, snake_case field names expected by backend. +- Include ISO8601 timestamps and parseable coordinate values. +- Keep envelope `type` values stable unless backend changes in same task. +- Prefer additive changes (new optional fields) over renames/removals. + +## Repo hygiene for agents + +- Keep edits minimal and task-scoped. +- Do not mass-reformat unrelated files. +- Document behavior changes in README or relevant docs when needed. +- If introducing new build/test tooling, add command docs to this file. diff --git a/MosswartMassacre.Loader/MosswartMassacre.Loader.csproj b/MosswartMassacre.Loader/MosswartMassacre.Loader.csproj index 59c8e03..3e3dbc2 100644 --- a/MosswartMassacre.Loader/MosswartMassacre.Loader.csproj +++ b/MosswartMassacre.Loader/MosswartMassacre.Loader.csproj @@ -30,8 +30,8 @@ False False - ..\..\..\..\..\..\Program Files (x86)\Decal 3.0\.NET 4.0 PIA\Decal.Interop.Core.DLL + ..\MosswartMassacre\lib\Decal.Interop.Core.DLL False - \ No newline at end of file + diff --git a/MosswartMassacre/MetaSyncManager.cs b/MosswartMassacre/MetaSyncManager.cs new file mode 100644 index 0000000..d52e20d --- /dev/null +++ b/MosswartMassacre/MetaSyncManager.cs @@ -0,0 +1,419 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Security.Cryptography; +using System.Threading.Tasks; +using Microsoft.Win32; +using Newtonsoft.Json.Linq; + +namespace MosswartMassacre +{ + internal enum MetaFileSyncStatus + { + Unknown, + NotDownloaded, + UpToDate, + UpdateAvailable, + Error + } + + internal sealed class MetaFileEntry + { + public string RelativePath { get; set; } + public string FileName { get; set; } + public string Extension { get; set; } + public string RemoteBlobSha { get; set; } + public string LocalPath { get; set; } + public bool LocalExists { get; set; } + public string LocalSha256 { get; set; } + public string RemoteSha256 { get; set; } + public MetaFileSyncStatus Status { get; set; } + public string StatusText { get; set; } + } + + internal sealed class MetaDownloadResult + { + public string RelativePath { get; set; } + public bool Downloaded { get; set; } + public bool SkippedAsCurrent { get; set; } + public bool BackupCreated { get; set; } + public string Message { get; set; } + } + + internal static class MetaSyncManager + { + private const string RepoOwner = "SawatoMosswartsEnjoyersClub"; + private const string RepoName = "metas"; + private const string BranchName = "main"; + private const string ApiBaseUrl = "https://git.snakedesert.se/api/v1"; + private const string RawBaseUrl = "https://git.snakedesert.se/SawatoMosswartsEnjoyersClub/metas/raw/branch/main"; + + private static readonly HttpClient HttpClient = new HttpClient { Timeout = TimeSpan.FromSeconds(30) }; + private static readonly object Sync = new object(); + private static List _cachedFiles = new List(); + + public static DateTime LastCatalogRefreshUtc { get; private set; } = DateTime.MinValue; + public static DateTime LastStatusCheckUtc { get; private set; } = DateTime.MinValue; + + public static IReadOnlyList GetCachedFiles() + { + lock (Sync) + { + return _cachedFiles + .Select(CloneEntry) + .ToList() + .AsReadOnly(); + } + } + + public static string GetResolvedVtankPath() + { + try + { + if (!string.IsNullOrWhiteSpace(PluginSettings.Instance?.VTankProfilesPath) && + Directory.Exists(PluginSettings.Instance.VTankProfilesPath)) + { + return PluginSettings.Instance.VTankProfilesPath; + } + + 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.IsNullOrWhiteSpace(profilePath) && Directory.Exists(profilePath)) + { + return profilePath; + } + } + } + catch + { + } + + return defaultPath; + } + catch + { + return string.Empty; + } + } + + public static async Task RefreshCatalogAsync() + { + var remoteEntries = await FetchRemoteTreeEntriesAsync(); + var vtankPath = GetResolvedVtankPath(); + + var result = remoteEntries + .Where(path => path.EndsWith(".met", StringComparison.OrdinalIgnoreCase) || + path.EndsWith(".nav", StringComparison.OrdinalIgnoreCase)) + .OrderBy(path => path, StringComparer.OrdinalIgnoreCase) + .Select(path => + { + var localPath = string.IsNullOrWhiteSpace(vtankPath) + ? string.Empty + : Path.Combine(vtankPath, Path.GetFileName(path)); + var exists = !string.IsNullOrWhiteSpace(localPath) && File.Exists(localPath); + return new MetaFileEntry + { + RelativePath = path, + FileName = Path.GetFileName(path), + Extension = Path.GetExtension(path)?.TrimStart('.').ToLowerInvariant() ?? string.Empty, + RemoteBlobSha = string.Empty, + LocalPath = localPath, + LocalExists = exists, + Status = exists ? MetaFileSyncStatus.Unknown : MetaFileSyncStatus.NotDownloaded, + StatusText = exists ? "Unknown" : "Not downloaded" + }; + }) + .ToList(); + + lock (Sync) + { + _cachedFiles = result; + LastCatalogRefreshUtc = DateTime.UtcNow; + } + + return result.Count; + } + + public static async Task<(int upToDate, int needsUpdate, int notDownloaded, int errors)> CheckStatusesAsync() + { + List workingSet; + lock (Sync) + { + workingSet = _cachedFiles.Select(CloneEntry).ToList(); + } + + if (workingSet.Count == 0) + { + await RefreshCatalogAsync(); + lock (Sync) + { + workingSet = _cachedFiles.Select(CloneEntry).ToList(); + } + } + + foreach (var entry in workingSet) + { + try + { + if (string.IsNullOrWhiteSpace(entry.LocalPath) || !File.Exists(entry.LocalPath)) + { + entry.LocalExists = false; + entry.LocalSha256 = string.Empty; + entry.Status = MetaFileSyncStatus.NotDownloaded; + entry.StatusText = "Not downloaded"; + continue; + } + + entry.LocalExists = true; + entry.LocalSha256 = CalculateFileSha256(entry.LocalPath); + + var remoteBytes = await DownloadRemoteFileBytesAsync(entry.RelativePath); + entry.RemoteSha256 = CalculateSha256(remoteBytes); + + if (string.Equals(entry.LocalSha256, entry.RemoteSha256, StringComparison.OrdinalIgnoreCase)) + { + entry.Status = MetaFileSyncStatus.UpToDate; + entry.StatusText = "Up to date"; + } + else + { + entry.Status = MetaFileSyncStatus.UpdateAvailable; + entry.StatusText = "Update available"; + } + } + catch (Exception ex) + { + entry.Status = MetaFileSyncStatus.Error; + entry.StatusText = "Error"; + PluginCore.WriteToChat($"[Metas] Status check failed for {entry.RelativePath}: {ex.Message}"); + } + } + + lock (Sync) + { + _cachedFiles = workingSet; + LastStatusCheckUtc = DateTime.UtcNow; + } + + var upToDate = workingSet.Count(x => x.Status == MetaFileSyncStatus.UpToDate); + var needsUpdate = workingSet.Count(x => x.Status == MetaFileSyncStatus.UpdateAvailable); + var notDownloaded = workingSet.Count(x => x.Status == MetaFileSyncStatus.NotDownloaded); + var errors = workingSet.Count(x => x.Status == MetaFileSyncStatus.Error); + + return (upToDate, needsUpdate, notDownloaded, errors); + } + + public static async Task DownloadFileAsync(string relativePath) + { + var result = new MetaDownloadResult + { + RelativePath = relativePath, + Downloaded = false, + SkippedAsCurrent = false, + BackupCreated = false, + Message = string.Empty + }; + + if (string.IsNullOrWhiteSpace(relativePath)) + { + result.Message = "No file selected."; + return result; + } + + var vtankPath = GetResolvedVtankPath(); + if (string.IsNullOrWhiteSpace(vtankPath)) + { + result.Message = "VTank path could not be resolved."; + return result; + } + + if (!Directory.Exists(vtankPath)) + { + Directory.CreateDirectory(vtankPath); + } + + var localPath = Path.Combine(vtankPath, Path.GetFileName(relativePath)); + var remoteBytes = await DownloadRemoteFileBytesAsync(relativePath); + var remoteHash = CalculateSha256(remoteBytes); + + if (File.Exists(localPath)) + { + var localHash = CalculateFileSha256(localPath); + if (string.Equals(localHash, remoteHash, StringComparison.OrdinalIgnoreCase)) + { + result.SkippedAsCurrent = true; + result.Message = "Already up to date."; + await UpdateSingleCachedEntryAsync(relativePath, localPath, localHash, remoteHash, MetaFileSyncStatus.UpToDate, "Up to date"); + return result; + } + + var backupPath = localPath + ".bak"; + File.Copy(localPath, backupPath, true); + result.BackupCreated = true; + } + + var tempPath = localPath + ".tmp"; + File.WriteAllBytes(tempPath, remoteBytes); + + if (File.Exists(localPath)) + { + File.Delete(localPath); + } + + File.Move(tempPath, localPath); + + result.Downloaded = true; + result.Message = result.BackupCreated ? "Downloaded and updated (.bak created)." : "Downloaded."; + + await UpdateSingleCachedEntryAsync(relativePath, localPath, CalculateFileSha256(localPath), remoteHash, MetaFileSyncStatus.UpToDate, "Up to date"); + + return result; + } + + public static async Task<(int downloaded, int skipped, int failed, int backups)> DownloadOutdatedAsync() + { + List current; + lock (Sync) + { + current = _cachedFiles.Select(CloneEntry).ToList(); + } + + if (current.Count == 0) + { + await RefreshCatalogAsync(); + current = GetCachedFiles().Select(CloneEntry).ToList(); + } + + var candidates = current + .Where(x => x.Status == MetaFileSyncStatus.UpdateAvailable || x.Status == MetaFileSyncStatus.NotDownloaded || x.Status == MetaFileSyncStatus.Unknown) + .OrderBy(x => x.RelativePath, StringComparer.OrdinalIgnoreCase) + .ToList(); + + var downloaded = 0; + var skipped = 0; + var failed = 0; + var backups = 0; + + foreach (var entry in candidates) + { + try + { + var result = await DownloadFileAsync(entry.RelativePath); + if (result.Downloaded) downloaded++; + if (result.SkippedAsCurrent) skipped++; + if (result.BackupCreated) backups++; + } + catch (Exception ex) + { + failed++; + PluginCore.WriteToChat($"[Metas] Download failed for {entry.RelativePath}: {ex.Message}"); + await UpdateSingleCachedEntryAsync(entry.RelativePath, entry.LocalPath, entry.LocalSha256, entry.RemoteSha256, MetaFileSyncStatus.Error, "Error"); + } + } + + return (downloaded, skipped, failed, backups); + } + + private static async Task> FetchRemoteTreeEntriesAsync() + { + var treeUrl = $"{ApiBaseUrl}/repos/{RepoOwner}/{RepoName}/git/trees/{BranchName}?recursive=1"; + using (var response = await HttpClient.GetAsync(treeUrl)) + { + response.EnsureSuccessStatusCode(); + var json = await response.Content.ReadAsStringAsync(); + var parsed = JObject.Parse(json); + var tree = parsed["tree"] as JArray; + if (tree == null) + { + return new List(); + } + + return tree + .Where(x => string.Equals(x["type"]?.ToString(), "blob", StringComparison.OrdinalIgnoreCase)) + .Select(x => x["path"]?.ToString()) + .Where(x => !string.IsNullOrWhiteSpace(x)) + .ToList(); + } + } + + private static async Task DownloadRemoteFileBytesAsync(string relativePath) + { + var escapedPath = string.Join("/", (relativePath ?? string.Empty) + .Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries) + .Select(Uri.EscapeDataString)); + var fileUrl = $"{RawBaseUrl}/{escapedPath}"; + return await HttpClient.GetByteArrayAsync(fileUrl); + } + + private static string CalculateFileSha256(string path) + { + using (var stream = File.OpenRead(path)) + { + using (var sha = SHA256.Create()) + { + return ToLowerHex(sha.ComputeHash(stream)); + } + } + } + + private static string CalculateSha256(byte[] data) + { + using (var sha = SHA256.Create()) + { + return ToLowerHex(sha.ComputeHash(data)); + } + } + + private static string ToLowerHex(byte[] hash) + { + return BitConverter.ToString(hash).Replace("-", string.Empty).ToLowerInvariant(); + } + + private static async Task UpdateSingleCachedEntryAsync(string relativePath, string localPath, string localHash, string remoteHash, MetaFileSyncStatus status, string statusText) + { + await Task.Run(() => + { + lock (Sync) + { + var target = _cachedFiles.FirstOrDefault(x => string.Equals(x.RelativePath, relativePath, StringComparison.OrdinalIgnoreCase)); + if (target == null) + { + return; + } + + target.LocalPath = localPath; + target.LocalExists = !string.IsNullOrWhiteSpace(localPath) && File.Exists(localPath); + target.LocalSha256 = localHash ?? string.Empty; + target.RemoteSha256 = remoteHash ?? string.Empty; + target.Status = status; + target.StatusText = statusText ?? "Unknown"; + } + }); + } + + private static MetaFileEntry CloneEntry(MetaFileEntry entry) + { + return new MetaFileEntry + { + RelativePath = entry.RelativePath, + FileName = entry.FileName, + Extension = entry.Extension, + RemoteBlobSha = entry.RemoteBlobSha, + LocalPath = entry.LocalPath, + LocalExists = entry.LocalExists, + LocalSha256 = entry.LocalSha256, + RemoteSha256 = entry.RemoteSha256, + Status = entry.Status, + StatusText = entry.StatusText + }; + } + } +} diff --git a/MosswartMassacre/MosswartMassacre.csproj b/MosswartMassacre/MosswartMassacre.csproj index 56d93d1..35c2a9a 100644 --- a/MosswartMassacre/MosswartMassacre.csproj +++ b/MosswartMassacre/MosswartMassacre.csproj @@ -321,6 +321,7 @@ + @@ -365,16 +366,6 @@ - - - lib\Decal.dll - False - - - lib\decalnet.dll - True - - @@ -401,4 +392,4 @@ - \ No newline at end of file + diff --git a/MosswartMassacre/PluginCore.cs b/MosswartMassacre/PluginCore.cs index b78ebb6..ac365f4 100644 --- a/MosswartMassacre/PluginCore.cs +++ b/MosswartMassacre/PluginCore.cs @@ -316,16 +316,7 @@ namespace MosswartMassacre } } - // 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(); + // Auto-update check is initialized after PluginSettings.Initialize() in LoginComplete } catch (Exception ex) @@ -484,6 +475,23 @@ namespace MosswartMassacre if (WebSocketEnabled) WebSocket.Start(); + if (PluginSettings.Instance.AutoUpdateEnabled) + { + _updateCheckTimer = new Timer(30000); + _updateCheckTimer.AutoReset = false; + _updateCheckTimer.Elapsed += (s, ev) => + { + Task.Run(() => UpdateManager.CheckAndInstallAsync()); + _updateCheckTimer?.Dispose(); + _updateCheckTimer = null; + }; + _updateCheckTimer.Start(); + } + else + { + WriteToChat("[Update] Auto updates disabled in settings"); + } + // Initialize Harmony patches using UtilityBelt's loaded DLL try { @@ -1526,6 +1534,77 @@ namespace MosswartMassacre WriteToChat("Settings not initialized"); } }, "Toggle verbose debug logging"); + + _commandRouter.Register("metasrefresh", args => + { + Task.Run(async () => + { + try + { + var count = await MetaSyncManager.RefreshCatalogAsync(); + WriteToChat($"[Metas] Loaded {count} remote .met/.nav files"); + } + catch (Exception ex) + { + WriteToChat($"[Metas] Refresh failed: {ex.Message}"); + } + }); + }, "Refresh remote metas list"); + + _commandRouter.Register("metascheck", args => + { + Task.Run(async () => + { + try + { + var summary = await MetaSyncManager.CheckStatusesAsync(); + WriteToChat($"[Metas] Up-to-date: {summary.upToDate}, Updates: {summary.needsUpdate}, Missing: {summary.notDownloaded}, Errors: {summary.errors}"); + } + catch (Exception ex) + { + WriteToChat($"[Metas] Check failed: {ex.Message}"); + } + }); + }, "Check local metas against remote"); + + _commandRouter.Register("metaspull", args => + { + Task.Run(async () => + { + try + { + var result = await MetaSyncManager.DownloadOutdatedAsync(); + WriteToChat($"[Metas] Downloaded: {result.downloaded}, Skipped: {result.skipped}, Failed: {result.failed}, Backups: {result.backups}"); + } + catch (Exception ex) + { + WriteToChat($"[Metas] Pull failed: {ex.Message}"); + } + }); + }, "Download outdated metas/nav files"); + + _commandRouter.Register("metaspullfile", args => + { + if (args.Length < 2) + { + WriteToChat("Usage: /mm metaspullfile "); + return; + } + + var relativePath = string.Join(" ", args.Skip(1)); + Task.Run(async () => + { + try + { + var result = await MetaSyncManager.DownloadFileAsync(relativePath); + WriteToChat($"[Metas] {relativePath}: {result.Message}"); + } + catch (Exception ex) + { + WriteToChat($"[Metas] Pull file failed: {ex.Message}"); + } + }); + }, "Download one metas/nav file by relative path"); } diff --git a/MosswartMassacre/PluginSettings.cs b/MosswartMassacre/PluginSettings.cs index 8104493..c411073 100644 --- a/MosswartMassacre/PluginSettings.cs +++ b/MosswartMassacre/PluginSettings.cs @@ -22,11 +22,14 @@ namespace MosswartMassacre private bool _useTabbedInterface = true; private string _vtankProfilesPath = ""; private bool _verboseLogging = false; + private bool _autoUpdateEnabled = true; private ChestLooterSettings _chestLooterSettings = new ChestLooterSettings(); public static PluginSettings Instance => _instance ?? throw new InvalidOperationException("PluginSettings not initialized"); + public static bool IsInitialized => _instance != null; + public static void Initialize() { // determine plugin folder and character-specific folder @@ -187,6 +190,12 @@ namespace MosswartMassacre set { _verboseLogging = value; Save(); } } + public bool AutoUpdateEnabled + { + get => _autoUpdateEnabled; + set { _autoUpdateEnabled = value; Save(); } + } + public ChestLooterSettings ChestLooterSettings { get diff --git a/MosswartMassacre/ViewXML/mainViewTabbed.xml b/MosswartMassacre/ViewXML/mainViewTabbed.xml index 2a3a86f..111e4fd 100644 --- a/MosswartMassacre/ViewXML/mainViewTabbed.xml +++ b/MosswartMassacre/ViewXML/mainViewTabbed.xml @@ -35,9 +35,12 @@ + + + - - + + @@ -146,5 +149,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + - \ No newline at end of file + diff --git a/MosswartMassacre/Views/VVSBaseView.cs b/MosswartMassacre/Views/VVSBaseView.cs index b61fb5f..2500f6b 100644 --- a/MosswartMassacre/Views/VVSBaseView.cs +++ b/MosswartMassacre/Views/VVSBaseView.cs @@ -155,7 +155,7 @@ namespace MosswartMassacre.Views { try { - if (view != null && PluginSettings.Instance != null) + if (view != null && PluginSettings.IsInitialized) { PluginSettings.Instance.MainWindowX = view.Location.X; PluginSettings.Instance.MainWindowY = view.Location.Y; @@ -171,7 +171,7 @@ namespace MosswartMassacre.Views { try { - if (view != null && PluginSettings.Instance != null) + if (view != null && PluginSettings.IsInitialized) { view.Location = new Point( PluginSettings.Instance.MainWindowX, @@ -392,4 +392,4 @@ namespace MosswartMassacre.Views } #endregion } -} \ No newline at end of file +} diff --git a/MosswartMassacre/Views/VVSTabbedMainView.cs b/MosswartMassacre/Views/VVSTabbedMainView.cs index cccbebc..f1201dc 100644 --- a/MosswartMassacre/Views/VVSTabbedMainView.cs +++ b/MosswartMassacre/Views/VVSTabbedMainView.cs @@ -1,5 +1,6 @@ using System; using System.Drawing; +using System.Linq; using System.Timers; using VirindiViewService.Controls; @@ -30,6 +31,7 @@ namespace MosswartMassacre.Views #region Settings Tab Controls private HudCheckBox chkRareMetaEnabled; private HudCheckBox chkWebSocketEnabled; + private HudCheckBox chkAutoUpdateEnabled; private HudTextBox txtCharTag; private HudTextBox txtVTankPath; #endregion @@ -70,6 +72,21 @@ namespace MosswartMassacre.Views private HudStaticText lblLooterStatus; #endregion + #region Metas Tab Controls + private HudStaticText lblMetasPath; + private HudButton btnRefreshMetasList; + private HudButton btnCheckMetasUpdates; + private HudButton btnDownloadOutdatedMetas; + private HudTextBox txtMetasSearch; + private HudButton btnClearMetasSearch; + private HudList lstMetasFiles; + private HudCombo cmbMetaSelection; + private HudButton btnDownloadSelectedMeta; + private HudStaticText lblMetasLastCheck; + private HudStaticText lblMetasStatus; + private string _metasSearchText = string.Empty; + #endregion + #region Statistics Tracking private double bestHourlyKills = 0; private DateTime sessionStartTime; @@ -149,6 +166,7 @@ namespace MosswartMassacre.Views InitializeNavigationTabControls(); InitializeFlagTrackerTabControls(); InitializeChestLooterTabControls(); + InitializeMetasTabControls(); // Initialize the base view and set initial position Initialize(); @@ -210,6 +228,7 @@ namespace MosswartMassacre.Views // Settings tab controls chkRareMetaEnabled = GetControl("chkRareMetaEnabled"); chkWebSocketEnabled = GetControl("chkWebSocketEnabled"); + chkAutoUpdateEnabled = GetControl("chkAutoUpdateEnabled"); txtCharTag = GetControl("txtCharTag"); txtVTankPath = GetControl("txtVTankPath"); @@ -218,6 +237,8 @@ namespace MosswartMassacre.Views chkRareMetaEnabled.Change += OnRareMetaSettingChanged; if (chkWebSocketEnabled != null) chkWebSocketEnabled.Change += OnWebSocketSettingChanged; + if (chkAutoUpdateEnabled != null) + chkAutoUpdateEnabled.Change += OnAutoUpdateSettingChanged; if (txtCharTag != null) txtCharTag.Change += OnCharTagChanged; if (txtVTankPath != null) @@ -338,6 +359,44 @@ namespace MosswartMassacre.Views PluginCore.WriteToChat($"Error initializing chest looter controls: {ex.Message}"); } } + + private void InitializeMetasTabControls() + { + try + { + lblMetasPath = GetControl("lblMetasPath"); + btnRefreshMetasList = GetControl("btnRefreshMetasList"); + btnCheckMetasUpdates = GetControl("btnCheckMetasUpdates"); + btnDownloadOutdatedMetas = GetControl("btnDownloadOutdatedMetas"); + txtMetasSearch = GetControl("txtMetasSearch"); + btnClearMetasSearch = GetControl("btnClearMetasSearch"); + lstMetasFiles = GetControl("lstMetasFiles"); + cmbMetaSelection = GetControl("cmbMetaSelection"); + btnDownloadSelectedMeta = GetControl("btnDownloadSelectedMeta"); + lblMetasLastCheck = GetControl("lblMetasLastCheck"); + lblMetasStatus = GetControl("lblMetasStatus"); + + if (btnRefreshMetasList != null) + btnRefreshMetasList.Hit += OnRefreshMetasListClick; + if (btnCheckMetasUpdates != null) + btnCheckMetasUpdates.Hit += OnCheckMetasUpdatesClick; + if (btnDownloadOutdatedMetas != null) + btnDownloadOutdatedMetas.Hit += OnDownloadOutdatedMetasClick; + if (txtMetasSearch != null) + txtMetasSearch.Change += OnMetasSearchChanged; + if (btnClearMetasSearch != null) + btnClearMetasSearch.Hit += OnClearMetasSearchClick; + if (btnDownloadSelectedMeta != null) + btnDownloadSelectedMeta.Hit += OnDownloadSelectedMetaClick; + + UpdateMetasPathLabel(); + UpdateMetasStatus("Status: idle"); + } + catch (Exception ex) + { + PluginCore.WriteToChat($"Error initializing metas controls: {ex.Message}"); + } + } #endregion #region Event Handlers - Settings Tab @@ -392,6 +451,19 @@ namespace MosswartMassacre.Views } } + private void OnAutoUpdateSettingChanged(object sender, EventArgs e) + { + try + { + PluginSettings.Instance.AutoUpdateEnabled = chkAutoUpdateEnabled.Checked; + PluginCore.WriteToChat($"Auto updates {(chkAutoUpdateEnabled.Checked ? "ENABLED" : "DISABLED")}." ); + } + catch (Exception ex) + { + PluginCore.WriteToChat($"Error in auto update setting change: {ex.Message}"); + } + } + private void OnVTankPathChanged(object sender, EventArgs e) { try @@ -543,12 +615,14 @@ namespace MosswartMassacre.Views { try { - if (PluginSettings.Instance != null) + if (PluginSettings.IsInitialized) { if (chkRareMetaEnabled != null) chkRareMetaEnabled.Checked = PluginSettings.Instance.RareMetaEnabled; if (chkWebSocketEnabled != null) chkWebSocketEnabled.Checked = PluginSettings.Instance.WebSocketEnabled; + if (chkAutoUpdateEnabled != null) + chkAutoUpdateEnabled.Checked = PluginSettings.Instance.AutoUpdateEnabled; if (txtCharTag != null) txtCharTag.Text = PluginSettings.Instance.CharTag ?? "default"; if (txtVTankPath != null) @@ -579,6 +653,166 @@ namespace MosswartMassacre.Views } #endregion + #region Event Handlers - Metas Tab + private void OnRefreshMetasListClick(object sender, EventArgs e) + { + try + { + UpdateMetasPathLabel(); + UpdateMetasStatus("Status: refreshing remote file list..."); + + System.Threading.Tasks.Task.Run(async () => + { + try + { + var count = await MetaSyncManager.RefreshCatalogAsync(); + PopulateMetasList(); + PopulateMetasSelection(); + UpdateMetasLastCheckLabel(); + UpdateMetasStatus($"Status: loaded {count} remote files"); + } + catch (Exception ex) + { + UpdateMetasStatus("Status: refresh failed"); + PluginCore.WriteToChat($"[Metas] Refresh failed: {ex.Message}"); + } + }); + } + catch (Exception ex) + { + PluginCore.WriteToChat($"[Metas] Refresh command failed: {ex.Message}"); + } + } + + private void OnCheckMetasUpdatesClick(object sender, EventArgs e) + { + try + { + UpdateMetasPathLabel(); + UpdateMetasStatus("Status: checking remote updates..."); + + System.Threading.Tasks.Task.Run(async () => + { + try + { + var summary = await MetaSyncManager.CheckStatusesAsync(); + PopulateMetasList(); + PopulateMetasSelection(); + UpdateMetasLastCheckLabel(); + UpdateMetasStatus($"Status: {summary.upToDate} current, {summary.needsUpdate} updates, {summary.notDownloaded} missing, {summary.errors} errors"); + } + catch (Exception ex) + { + UpdateMetasStatus("Status: update check failed"); + PluginCore.WriteToChat($"[Metas] Update check failed: {ex.Message}"); + } + }); + } + catch (Exception ex) + { + PluginCore.WriteToChat($"[Metas] Update check command failed: {ex.Message}"); + } + } + + private void OnDownloadOutdatedMetasClick(object sender, EventArgs e) + { + try + { + UpdateMetasStatus("Status: downloading outdated files..."); + + System.Threading.Tasks.Task.Run(async () => + { + try + { + var result = await MetaSyncManager.DownloadOutdatedAsync(); + PopulateMetasList(); + PopulateMetasSelection(); + UpdateMetasLastCheckLabel(); + UpdateMetasStatus($"Status: downloaded {result.downloaded}, skipped {result.skipped}, failed {result.failed}, backups {result.backups}"); + } + catch (Exception ex) + { + UpdateMetasStatus("Status: pull outdated failed"); + PluginCore.WriteToChat($"[Metas] Pull outdated failed: {ex.Message}"); + } + }); + } + catch (Exception ex) + { + PluginCore.WriteToChat($"[Metas] Pull outdated command failed: {ex.Message}"); + } + } + + private void OnDownloadSelectedMetaClick(object sender, EventArgs e) + { + try + { + string selection = GetSelectedMetaRelativePath(); + if (string.IsNullOrWhiteSpace(selection)) + { + PluginCore.WriteToChat("[Metas] Select a file first"); + return; + } + + UpdateMetasStatus($"Status: downloading {selection}..."); + + System.Threading.Tasks.Task.Run(async () => + { + try + { + var result = await MetaSyncManager.DownloadFileAsync(selection); + PopulateMetasList(); + PopulateMetasSelection(); + UpdateMetasLastCheckLabel(); + UpdateMetasStatus($"Status: {result.Message}"); + } + catch (Exception ex) + { + UpdateMetasStatus("Status: download failed"); + PluginCore.WriteToChat($"[Metas] Download failed: {ex.Message}"); + } + }); + } + catch (Exception ex) + { + PluginCore.WriteToChat($"[Metas] Download command failed: {ex.Message}"); + } + } + + private void OnMetasSearchChanged(object sender, EventArgs e) + { + try + { + _metasSearchText = txtMetasSearch?.Text?.Trim() ?? string.Empty; + PopulateMetasList(); + PopulateMetasSelection(); + } + catch (Exception ex) + { + PluginCore.WriteToChat($"[Metas] Search failed: {ex.Message}"); + } + } + + private void OnClearMetasSearchClick(object sender, EventArgs e) + { + try + { + _metasSearchText = string.Empty; + if (txtMetasSearch != null) + { + txtMetasSearch.Text = string.Empty; + } + + PopulateMetasList(); + PopulateMetasSelection(); + } + catch (Exception ex) + { + PluginCore.WriteToChat($"[Metas] Clear search failed: {ex.Message}"); + } + } + #endregion + #region Event Handlers - Flag Tracker Tab private void OnOpenFlagTrackerClick(object sender, EventArgs e) { @@ -732,6 +966,11 @@ namespace MosswartMassacre.Views { try { + if (!PluginSettings.IsInitialized) + { + return; + } + var settings = PluginSettings.Instance?.ChestLooterSettings; if (settings != null) { @@ -1079,7 +1318,7 @@ namespace MosswartMassacre.Views { if (lblAutoLootRare != null) { - bool isEnabled = PluginSettings.Instance?.RareMetaEnabled == true; + bool isEnabled = PluginSettings.IsInitialized && PluginSettings.Instance.RareMetaEnabled; if (isEnabled) { lblAutoLootRare.Text = "Auto Loot Rare: [ON]"; @@ -1107,7 +1346,7 @@ namespace MosswartMassacre.Views { if (lblWebSocketStatus != null) { - bool isConnected = PluginSettings.Instance?.WebSocketEnabled == true; + bool isConnected = PluginSettings.IsInitialized && PluginSettings.Instance.WebSocketEnabled; if (isConnected) { lblWebSocketStatus.Text = "WebSocket: [CONNECTED]"; @@ -1182,6 +1421,264 @@ namespace MosswartMassacre.Views PluginCore.WriteToChat($"Error populating nav file dropdown: {ex.Message}"); } } + + private void PopulateMetasList() + { + try + { + if (lstMetasFiles == null) + { + return; + } + + lstMetasFiles.ClearRows(); + var files = ApplyMetasSearch(MetaSyncManager.GetCachedFiles()); + + if (files == null || files.Count == 0) + { + var emptyRow = lstMetasFiles.AddRow(); + SafeSetListText(emptyRow, 0, "-"); + SafeSetListText(emptyRow, 1, string.IsNullOrWhiteSpace(_metasSearchText) ? "No files loaded" : "No matches"); + SafeSetListText(emptyRow, 2, string.IsNullOrWhiteSpace(_metasSearchText) ? "Click Refresh List" : "Try a different filter"); + return; + } + + foreach (var file in files) + { + var row = lstMetasFiles.AddRow(); + SafeSetListText(row, 0, file.Extension?.ToUpperInvariant() ?? ""); + SafeSetListText(row, 1, file.RelativePath ?? file.FileName ?? ""); + SafeSetListText(row, 2, file.StatusText ?? "Unknown"); + + Color color; + switch (file.Status) + { + case MetaFileSyncStatus.UpToDate: + color = Color.FromArgb(0, 128, 0); + break; + case MetaFileSyncStatus.UpdateAvailable: + color = Color.FromArgb(180, 110, 0); + break; + case MetaFileSyncStatus.NotDownloaded: + color = Color.FromArgb(0, 70, 160); + break; + case MetaFileSyncStatus.Error: + color = Color.FromArgb(180, 0, 0); + break; + default: + color = Color.White; + break; + } + + SafeSetListColor(row, 2, color); + } + } + catch (Exception ex) + { + PluginCore.WriteToChat($"[Metas] Error populating list: {ex.Message}"); + } + } + + private void PopulateMetasSelection() + { + try + { + if (cmbMetaSelection == null) + { + return; + } + + var previouslySelected = GetSelectedMetaRelativePath(); + var files = ApplyMetasSearch(MetaSyncManager.GetCachedFiles()) + .OrderBy(x => x.RelativePath, StringComparer.OrdinalIgnoreCase) + .ToList(); + + cmbMetaSelection.Clear(); + + foreach (var file in files) + { + var label = $"[{file.Extension}] {file.RelativePath}"; + cmbMetaSelection.AddItem(label, file.RelativePath); + } + + if (cmbMetaSelection.Count > 0) + { + var selectedIndex = 0; + if (!string.IsNullOrWhiteSpace(previouslySelected)) + { + for (int i = 0; i < cmbMetaSelection.Count; i++) + { + var value = ((HudStaticText)cmbMetaSelection[i]).Text; + if (!string.IsNullOrWhiteSpace(value) && value.EndsWith(previouslySelected, StringComparison.OrdinalIgnoreCase)) + { + selectedIndex = i; + break; + } + } + } + + cmbMetaSelection.Current = selectedIndex; + } + } + catch (Exception ex) + { + PluginCore.WriteToChat($"[Metas] Error populating selection: {ex.Message}"); + } + } + + private string GetSelectedMetaRelativePath() + { + try + { + if (cmbMetaSelection == null || cmbMetaSelection.Count == 0 || cmbMetaSelection.Current < 0 || cmbMetaSelection.Current >= cmbMetaSelection.Count) + { + return string.Empty; + } + + var text = ((HudStaticText)cmbMetaSelection[cmbMetaSelection.Current]).Text ?? string.Empty; + var closeBracket = text.IndexOf(']'); + if (closeBracket >= 0 && closeBracket + 2 < text.Length) + { + return text.Substring(closeBracket + 2).Trim(); + } + + return text.Trim(); + } + catch + { + return string.Empty; + } + } + + private void UpdateMetasPathLabel() + { + try + { + if (lblMetasPath == null) + { + return; + } + + var path = MetaSyncManager.GetResolvedVtankPath(); + if (string.IsNullOrWhiteSpace(path)) + { + lblMetasPath.Text = "VTank Path: unresolved"; + } + else + { + lblMetasPath.Text = $"VTank Path: {path}"; + } + } + catch + { + if (lblMetasPath != null) + { + lblMetasPath.Text = "VTank Path: unresolved"; + } + } + } + + private void UpdateMetasLastCheckLabel() + { + try + { + if (lblMetasLastCheck == null) + { + return; + } + + var ts = MetaSyncManager.LastStatusCheckUtc != DateTime.MinValue + ? MetaSyncManager.LastStatusCheckUtc.ToLocalTime() + : MetaSyncManager.LastCatalogRefreshUtc.ToLocalTime(); + + if (ts == DateTime.MinValue) + { + lblMetasLastCheck.Text = "Last Check: never"; + } + else + { + lblMetasLastCheck.Text = $"Last Check: {ts:yyyy-MM-dd HH:mm:ss}"; + } + } + catch + { + if (lblMetasLastCheck != null) + { + lblMetasLastCheck.Text = "Last Check: unknown"; + } + } + } + + private void UpdateMetasStatus(string text) + { + try + { + if (lblMetasStatus != null) + { + lblMetasStatus.Text = text ?? "Status: idle"; + } + } + catch + { + } + } + + private System.Collections.Generic.IReadOnlyList ApplyMetasSearch(System.Collections.Generic.IReadOnlyList files) + { + if (files == null || files.Count == 0) + { + return files; + } + + var filter = _metasSearchText?.Trim(); + if (string.IsNullOrWhiteSpace(filter)) + { + return files; + } + + return files + .Where(f => (!string.IsNullOrWhiteSpace(f.RelativePath) && f.RelativePath.IndexOf(filter, StringComparison.OrdinalIgnoreCase) >= 0) + || (!string.IsNullOrWhiteSpace(f.FileName) && f.FileName.IndexOf(filter, StringComparison.OrdinalIgnoreCase) >= 0)) + .ToList(); + } + + private void SafeSetListText(HudList.HudListRowAccessor row, int columnIndex, string text) + { + try + { + if (row != null && columnIndex >= 0) + { + try + { + var control = row[columnIndex]; + if (control != null) + { + ((HudStaticText)control).Text = text ?? string.Empty; + } + } + catch + { + } + } + } + catch + { + } + } + + private void SafeSetListColor(HudList.HudListRowAccessor row, int columnIndex, Color color) + { + try + { + if (row != null && columnIndex >= 0 && row[columnIndex] != null) + { + ((HudStaticText)row[columnIndex]).TextColor = color; + } + } + catch + { + } + } #endregion #region IDisposable Override @@ -1198,6 +1695,8 @@ namespace MosswartMassacre.Views chkRareMetaEnabled.Change -= OnRareMetaSettingChanged; if (chkWebSocketEnabled != null) chkWebSocketEnabled.Change -= OnWebSocketSettingChanged; + if (chkAutoUpdateEnabled != null) + chkAutoUpdateEnabled.Change -= OnAutoUpdateSettingChanged; if (txtCharTag != null) txtCharTag.Change -= OnCharTagChanged; if (txtVTankPath != null) @@ -1220,6 +1719,19 @@ namespace MosswartMassacre.Views btnLoadRoute.Hit -= OnLoadRouteClick; if (btnClearRoute != null) btnClearRoute.Hit -= OnClearRouteClick; + + if (btnRefreshMetasList != null) + btnRefreshMetasList.Hit -= OnRefreshMetasListClick; + if (btnCheckMetasUpdates != null) + btnCheckMetasUpdates.Hit -= OnCheckMetasUpdatesClick; + if (btnDownloadOutdatedMetas != null) + btnDownloadOutdatedMetas.Hit -= OnDownloadOutdatedMetasClick; + if (txtMetasSearch != null) + txtMetasSearch.Change -= OnMetasSearchChanged; + if (btnClearMetasSearch != null) + btnClearMetasSearch.Hit -= OnClearMetasSearchClick; + if (btnDownloadSelectedMeta != null) + btnDownloadSelectedMeta.Hit -= OnDownloadSelectedMetaClick; // No enhanced events to clean up } @@ -1234,4 +1746,4 @@ namespace MosswartMassacre.Views } #endregion } -} \ No newline at end of file +} diff --git a/MosswartMassacre/bin/Release/MosswartMassacre.dll b/MosswartMassacre/bin/Release/MosswartMassacre.dll index aec25fa..4eb7303 100644 Binary files a/MosswartMassacre/bin/Release/MosswartMassacre.dll and b/MosswartMassacre/bin/Release/MosswartMassacre.dll differ