feat: add searchable metas sync tab and configurable auto-updates

Add a Metas tab that lists remote .met/.nav files, checks update status, and downloads with .bak backups on overwrite. Add an Auto Install Updates setting (default on) and guard settings usage during early startup to avoid initialization errors.
This commit is contained in:
Erik 2026-03-09 11:36:47 +01:00
parent 278802c0af
commit e20f9df256
10 changed files with 1201 additions and 33 deletions

127
AGENTS.md Normal file
View file

@ -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.

View file

@ -30,8 +30,8 @@
<Reference Include="Decal.Interop.Core, Version=2.9.8.3, Culture=neutral, PublicKeyToken=481f17d392f1fb65, processorArchitecture=MSIL">
<SpecificVersion>False</SpecificVersion>
<EmbedInteropTypes>False</EmbedInteropTypes>
<HintPath>..\..\..\..\..\..\Program Files (x86)\Decal 3.0\.NET 4.0 PIA\Decal.Interop.Core.DLL</HintPath>
<HintPath>..\MosswartMassacre\lib\Decal.Interop.Core.DLL</HintPath>
<Private>False</Private>
</Reference>
</ItemGroup>
</Project>
</Project>

View file

@ -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<MetaFileEntry> _cachedFiles = new List<MetaFileEntry>();
public static DateTime LastCatalogRefreshUtc { get; private set; } = DateTime.MinValue;
public static DateTime LastStatusCheckUtc { get; private set; } = DateTime.MinValue;
public static IReadOnlyList<MetaFileEntry> 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<int> 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<MetaFileEntry> 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<MetaDownloadResult> 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<MetaFileEntry> 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<List<string>> 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<string>();
}
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<byte[]> 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
};
}
}
}

View file

@ -321,6 +321,7 @@
<Compile Include="MossyInventory.cs" />
<Compile Include="NavRoute.cs" />
<Compile Include="NavVisualization.cs" />
<Compile Include="MetaSyncManager.cs" />
<Compile Include="QuestManager.cs" />
<Compile Include="vTank.cs" />
<Compile Include="VtankControl.cs" />
@ -365,16 +366,6 @@
<None Include="app.config" />
<None Include="packages.config" />
</ItemGroup>
<ItemGroup>
<Reference Include="Decal">
<HintPath>lib\Decal.dll</HintPath>
<EmbedInteropTypes>False</EmbedInteropTypes>
</Reference>
<Reference Include="DecalNet">
<HintPath>lib\decalnet.dll</HintPath>
<EmbedInteropTypes>True</EmbedInteropTypes>
</Reference>
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NETFramework.ReferenceAssemblies" Version="1.0.3" PrivateAssets="All" />
</ItemGroup>
@ -401,4 +392,4 @@
<Compile Include="$(CalVerFile)" />
</ItemGroup>
</Target>
</Project>
</Project>

View file

@ -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 <relative/path/file.met>");
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");
}

View file

@ -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

View file

@ -35,9 +35,12 @@
<!-- WebSocket setting -->
<control progid="DecalControls.Checkbox" name="chkWebSocketEnabled" left="20" top="60" width="300" height="20" text="WebSocket streaming" checked="false"/>
<!-- Auto-update setting -->
<control progid="DecalControls.Checkbox" name="chkAutoUpdateEnabled" left="20" top="85" width="300" height="20" text="Auto install plugin updates" checked="true"/>
<!-- Character tag setting -->
<control progid="DecalControls.StaticText" name="lblCharTag" left="20" top="90" width="100" height="16" text="Character Tag:"/>
<control progid="DecalControls.Edit" name="txtCharTag" left="125" top="88" width="150" height="20" text="default"/>
<control progid="DecalControls.StaticText" name="lblCharTag" left="20" top="115" width="100" height="16" text="Character Tag:"/>
<control progid="DecalControls.Edit" name="txtCharTag" left="125" top="113" width="150" height="20" text="default"/>
<!-- VTank profiles path setting -->
<control progid="DecalControls.StaticText" name="lblVTankPath" left="20" top="190" width="100" height="16" text="VTank Profiles:"/>
@ -146,5 +149,33 @@
</control>
</page>
<page label="Metas">
<control progid="DecalControls.FixedLayout" clipped="">
<control progid="DecalControls.StaticText" name="lblMetasHeader" left="10" top="8" width="380" height="16" text="Remote .met/.nav files from metas repo" style="FontBold"/>
<control progid="DecalControls.StaticText" name="lblMetasPath" left="10" top="25" width="390" height="16" text="VTank Path: unresolved"/>
<control progid="DecalControls.PushButton" name="btnRefreshMetasList" left="10" top="45" width="95" height="22" text="Refresh List"/>
<control progid="DecalControls.PushButton" name="btnCheckMetasUpdates" left="110" top="45" width="95" height="22" text="Check Updates"/>
<control progid="DecalControls.PushButton" name="btnDownloadOutdatedMetas" left="210" top="45" width="120" height="22" text="Pull Outdated"/>
<control progid="DecalControls.StaticText" name="lblMetasSearch" left="10" top="70" width="45" height="16" text="Search:"/>
<control progid="DecalControls.Edit" name="txtMetasSearch" left="58" top="68" width="212" height="20" text=""/>
<control progid="DecalControls.PushButton" name="btnClearMetasSearch" left="275" top="67" width="55" height="22" text="Clear"/>
<control progid="DecalControls.List" name="lstMetasFiles" left="10" top="94" width="400" height="128">
<column progid="DecalControls.TextColumn" fixedwidth="45" name="Type" />
<column progid="DecalControls.TextColumn" fixedwidth="230" name="Path" />
<column progid="DecalControls.TextColumn" fixedwidth="120" name="Status" />
</control>
<control progid="DecalControls.StaticText" name="lblMetaSelection" left="10" top="228" width="120" height="16" text="Selected file:"/>
<control progid="DecalControls.Choice" name="cmbMetaSelection" left="95" top="226" width="225" height="20"/>
<control progid="DecalControls.PushButton" name="btnDownloadSelectedMeta" left="325" top="225" width="85" height="22" text="Download"/>
<control progid="DecalControls.StaticText" name="lblMetasLastCheck" left="10" top="250" width="390" height="16" text="Last Check: never"/>
<control progid="DecalControls.StaticText" name="lblMetasStatus" left="10" top="268" width="390" height="16" text="Status: idle"/>
</control>
</page>
</control>
</view>
</view>

View file

@ -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
}
}
}

View file

@ -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<HudCheckBox>("chkRareMetaEnabled");
chkWebSocketEnabled = GetControl<HudCheckBox>("chkWebSocketEnabled");
chkAutoUpdateEnabled = GetControl<HudCheckBox>("chkAutoUpdateEnabled");
txtCharTag = GetControl<HudTextBox>("txtCharTag");
txtVTankPath = GetControl<HudTextBox>("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<HudStaticText>("lblMetasPath");
btnRefreshMetasList = GetControl<HudButton>("btnRefreshMetasList");
btnCheckMetasUpdates = GetControl<HudButton>("btnCheckMetasUpdates");
btnDownloadOutdatedMetas = GetControl<HudButton>("btnDownloadOutdatedMetas");
txtMetasSearch = GetControl<HudTextBox>("txtMetasSearch");
btnClearMetasSearch = GetControl<HudButton>("btnClearMetasSearch");
lstMetasFiles = GetControl<HudList>("lstMetasFiles");
cmbMetaSelection = GetControl<HudCombo>("cmbMetaSelection");
btnDownloadSelectedMeta = GetControl<HudButton>("btnDownloadSelectedMeta");
lblMetasLastCheck = GetControl<HudStaticText>("lblMetasLastCheck");
lblMetasStatus = GetControl<HudStaticText>("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<MetaFileEntry> ApplyMetasSearch(System.Collections.Generic.IReadOnlyList<MetaFileEntry> 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
}
}
}