merge(vt): slice 1 Part A — VTank .usd/.utl drop-in and metaf .af for metas and routes (review-closed)
Campaign VT slice 1 Part A: the .usd document model + 137-setting serializer with declared type tags and exact compare, metaf .af reader/ writer for metas and nav routes with real byte identity against the owner's fixtures, .utl gate fixes, the VtankProfiles host storage (ACDREAM_VTANK_PROFILE_DIR), and the cutover of all four profile stores to real VTank files with one-time JSON migration. Two Opus lenses, three fix rounds, two narrow re-reviews, final re-check: MERGE-READY. Contract-doc ledger conflict resolved by keeping the campaign branch. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
commit
f680bf234a
108 changed files with 62970 additions and 1860 deletions
|
|
@ -13,7 +13,8 @@ public sealed class AppPluginHost : IPluginHost
|
|||
IAutomationSurface automation,
|
||||
IPluginStorage? storage = null,
|
||||
IPluginCommandRegistry? commands = null,
|
||||
IPluginLootClassifierRegistry? lootClassifiers = null)
|
||||
IPluginLootClassifierRegistry? lootClassifiers = null,
|
||||
IPluginStorage? vtankProfiles = null)
|
||||
{
|
||||
Log = log;
|
||||
State = state;
|
||||
|
|
@ -25,6 +26,7 @@ public sealed class AppPluginHost : IPluginHost
|
|||
Commands = commands ?? NoOpPluginCommandRegistry.Instance;
|
||||
LootClassifiers = lootClassifiers
|
||||
?? NoOpPluginLootClassifierRegistry.Instance;
|
||||
VtankProfiles = vtankProfiles ?? NoOpPluginStorage.Instance;
|
||||
}
|
||||
|
||||
public bool HasUi => true;
|
||||
|
|
@ -37,4 +39,5 @@ public sealed class AppPluginHost : IPluginHost
|
|||
public IPluginStorage Storage { get; }
|
||||
public IPluginCommandRegistry Commands { get; }
|
||||
public IPluginLootClassifierRegistry LootClassifiers { get; }
|
||||
public IPluginStorage VtankProfiles { get; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,7 +26,12 @@ internal sealed class FilePluginStorage : IPluginStorage
|
|||
|
||||
public IReadOnlyList<string> List(string prefix)
|
||||
{
|
||||
string directory = Resolve(prefix);
|
||||
ArgumentNullException.ThrowIfNull(prefix);
|
||||
// An empty prefix means "the storage root itself" — Resolve()
|
||||
// rejects an empty/whitespace key (every other caller of it means
|
||||
// one specific file or sub-directory), so this is handled directly
|
||||
// rather than relaxing that guard for every other use.
|
||||
string directory = prefix.Length == 0 ? _root : Resolve(prefix);
|
||||
if (!Directory.Exists(directory))
|
||||
return Array.Empty<string>();
|
||||
return Directory.EnumerateFiles(directory, "*", SearchOption.AllDirectories)
|
||||
|
|
|
|||
16
src/AcDream.App/Plugins/VtankProfilesDefault.cs
Normal file
16
src/AcDream.App/Plugins/VtankProfilesDefault.cs
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
namespace AcDream.App.Plugins;
|
||||
|
||||
/// <summary>
|
||||
/// The graphical host's default root for <c>IPluginHost.VtankProfiles</c>
|
||||
/// when <c>RuntimeOptions.VtankProfileDirectoryOverride</c>
|
||||
/// (<c>ACDREAM_VTANK_PROFILE_DIR</c>) is unset — extracted out of
|
||||
/// <c>Program.cs</c>'s inline composition into its own pure, injectable-root
|
||||
/// function so the "built with <see cref="Path.Combine(string, string)"/>
|
||||
/// only, never a hard-coded Windows path" guarantee is a real, failable unit
|
||||
/// test rather than something only checkable by reading the source.
|
||||
/// </summary>
|
||||
internal static class VtankProfilesDefault
|
||||
{
|
||||
internal static string Resolve(string dataDirectory) =>
|
||||
Path.Combine(dataDirectory, "vtank");
|
||||
}
|
||||
|
|
@ -185,7 +185,10 @@ var host = new AppPluginHost(
|
|||
new FilePluginStorage(
|
||||
Path.Combine(applicationPaths.ConfigDirectory, "plugins")),
|
||||
automation.PluginCommands,
|
||||
lootClassifiers);
|
||||
lootClassifiers,
|
||||
new FilePluginStorage(
|
||||
runtimeOptions.VtankProfileDirectoryOverride
|
||||
?? VtankProfilesDefault.Resolve(applicationPaths.DataDirectory)));
|
||||
GraphicalPluginSession pluginSession = GraphicalPluginSession.Create(
|
||||
applicationPaths,
|
||||
runtimeOptions.Plugins,
|
||||
|
|
|
|||
|
|
@ -136,6 +136,17 @@ public sealed record RuntimeOptions(
|
|||
/// process configuration directly.</summary>
|
||||
public IReadOnlyList<string> PluginTags { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// <c>ACDREAM_VTANK_PROFILE_DIR</c> override for the directory a
|
||||
/// VTank-compatible plugin's <c>IPluginHost.VtankProfiles</c> storage is
|
||||
/// rooted at (real <c>.usd</c>/<c>.ast</c>/<c>.af</c> files — e.g. a real
|
||||
/// installed VTank's own profile folder for direct interop).
|
||||
/// <see langword="null"/> (the default) means "no opinion": the host
|
||||
/// composes <c>applicationPaths.DataDirectory/vtank</c> instead. See
|
||||
/// <c>docs/launch-options.md</c>.
|
||||
/// </summary>
|
||||
public string? VtankProfileDirectoryOverride { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Build options from the process environment. Used by
|
||||
/// <c>Program.cs</c> at startup.
|
||||
|
|
@ -271,6 +282,7 @@ public sealed record RuntimeOptions(
|
|||
LoginCommandDelayMs: 500)
|
||||
{
|
||||
PluginTags = ParsePluginTags(env("ACDREAM_PLUGIN_TAGS")),
|
||||
VtankProfileDirectoryOverride = NullIfEmpty(env("ACDREAM_VTANK_PROFILE_DIR")),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -46,6 +46,14 @@ internal sealed class ScopedPluginHost : IPluginHost, IDisposable
|
|||
public ISelectionService Selection => _selection;
|
||||
public IUiRegistry Ui => _ui;
|
||||
public IPluginStorage Storage => _storage;
|
||||
/// <summary>
|
||||
/// Forwarded, not scoped, unlike <see cref="Storage"/>: the VTank
|
||||
/// profile folder is one shared external location (real VTank's own
|
||||
/// files, or a host-composed portable default), not per-plugin data —
|
||||
/// scoping it under this plugin's manifest id would defeat the whole
|
||||
/// point of pointing it at a real installed VTank profile directory.
|
||||
/// </summary>
|
||||
public IPluginStorage VtankProfiles => _inner.VtankProfiles;
|
||||
public IPluginCommandRegistry Commands => _commands;
|
||||
public IPluginLootClassifierRegistry LootClassifiers => _lootClassifiers;
|
||||
|
||||
|
|
|
|||
|
|
@ -61,6 +61,8 @@ internal sealed class HeadlessProcessHost : IDisposable
|
|||
Path.Combine(AppContext.BaseDirectory, "plugins"),
|
||||
paths.PluginsDirectory,
|
||||
];
|
||||
var vtankProfiles = new AcDream.Headless.Plugins.FilePluginStorage(
|
||||
paths.VtankProfilesDirectory);
|
||||
HeadlessProcessContentOwner? content = null;
|
||||
HeadlessProcessResourceSampler? resources = null;
|
||||
// FA6: constructed unconditionally — cheap, and every non-gate
|
||||
|
|
@ -110,7 +112,8 @@ internal sealed class HeadlessProcessHost : IDisposable
|
|||
timeProvider,
|
||||
contentLease: contentLease,
|
||||
gateCoordinator: gateCoordinator,
|
||||
pluginRoots: pluginRoots));
|
||||
pluginRoots: pluginRoots,
|
||||
vtankProfiles: vtankProfiles));
|
||||
}
|
||||
catch
|
||||
{
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ using AcDream.Headless.Credentials;
|
|||
using AcDream.Headless.Diagnostics;
|
||||
using AcDream.Headless.Plugins;
|
||||
using AcDream.Headless.Policies;
|
||||
using AcDream.Plugin.Abstractions;
|
||||
using AcDream.Content.CharGen;
|
||||
using AcDream.Core.Chat;
|
||||
using AcDream.Core.Net.Messages;
|
||||
|
|
@ -266,7 +267,8 @@ internal sealed class HeadlessSessionHost : IDisposable
|
|||
IHeadlessBotPolicy? policyOverride = null,
|
||||
IRuntimePlacementProjectionSink? placementSinkOverride = null,
|
||||
FellowshipAllegianceGateCoordinator? gateCoordinator = null,
|
||||
IEnumerable<string>? pluginRoots = null)
|
||||
IEnumerable<string>? pluginRoots = null,
|
||||
IPluginStorage? vtankProfiles = null)
|
||||
{
|
||||
_descriptor = descriptor
|
||||
?? throw new ArgumentNullException(nameof(descriptor));
|
||||
|
|
@ -367,7 +369,8 @@ internal sealed class HeadlessSessionHost : IDisposable
|
|||
descriptor.Id,
|
||||
pluginRoots ?? [],
|
||||
descriptor.Plugins,
|
||||
pluginCommands);
|
||||
pluginCommands,
|
||||
vtankProfiles);
|
||||
var liveSession = new LiveSessionHost(
|
||||
runtime.Session,
|
||||
new LiveSessionHostBindings(
|
||||
|
|
|
|||
|
|
@ -11,6 +11,17 @@ internal sealed record HeadlessPathSet(
|
|||
internal string PluginsDirectory =>
|
||||
Path.Combine(DataDirectory, "plugins");
|
||||
|
||||
/// <summary>
|
||||
/// Default root for <see cref="AcDream.Plugin.Abstractions.IPluginHost.VtankProfiles"/>
|
||||
/// (Campaign VT slice-1 fix round, item F). No override mechanism of its
|
||||
/// own yet — unlike the graphical host's <c>ACDREAM_VTANK_PROFILE_DIR</c>,
|
||||
/// which only exists on <c>AcDream.App.RuntimeOptions</c> — because
|
||||
/// headless path overrides already go through <c>HeadlessPathOverrides</c>
|
||||
/// (config file / <c>--data-dir</c>), not environment variables.
|
||||
/// </summary>
|
||||
internal string VtankProfilesDirectory =>
|
||||
Path.Combine(DataDirectory, "vtank");
|
||||
|
||||
internal static HeadlessPathSet Resolve(
|
||||
HeadlessPathOverrides overrides,
|
||||
IHeadlessPlatformEnvironment? platform = null)
|
||||
|
|
|
|||
99
src/AcDream.Headless/Plugins/FilePluginStorage.cs
Normal file
99
src/AcDream.Headless/Plugins/FilePluginStorage.cs
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
using System.Text;
|
||||
using AcDream.Plugin.Abstractions;
|
||||
|
||||
namespace AcDream.Headless.Plugins;
|
||||
|
||||
/// <summary>
|
||||
/// Crash-safe filesystem <see cref="IPluginStorage"/> — byte-identical
|
||||
/// contract to <c>AcDream.App.Plugins.FilePluginStorage</c>. Duplicated
|
||||
/// rather than shared: <c>AcDream.Headless</c> does not (and per the
|
||||
/// no-window/graphical layer split should not) reference <c>AcDream.App</c>,
|
||||
/// and no shared "platform plugins" library exists yet to host one copy of
|
||||
/// this ~70-line class for both hosts. Promoting it there is a reasonable
|
||||
/// future cleanup, not required for Campaign VT slice-1 item F.
|
||||
/// </summary>
|
||||
internal sealed class FilePluginStorage : IPluginStorage
|
||||
{
|
||||
private readonly string _root;
|
||||
|
||||
internal FilePluginStorage(string root)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(root);
|
||||
_root = Path.GetFullPath(root);
|
||||
}
|
||||
|
||||
public bool IsAvailable => true;
|
||||
|
||||
public string? ReadText(string key)
|
||||
{
|
||||
string path = Resolve(key);
|
||||
return File.Exists(path)
|
||||
? File.ReadAllText(path, Encoding.UTF8)
|
||||
: null;
|
||||
}
|
||||
|
||||
public IReadOnlyList<string> List(string prefix)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(prefix);
|
||||
// An empty prefix means "the storage root itself" — Resolve()
|
||||
// rejects an empty/whitespace key (every other caller of it means
|
||||
// one specific file or sub-directory), so this is handled directly
|
||||
// rather than relaxing that guard for every other use.
|
||||
string directory = prefix.Length == 0 ? _root : Resolve(prefix);
|
||||
if (!Directory.Exists(directory))
|
||||
return Array.Empty<string>();
|
||||
return Directory.EnumerateFiles(directory, "*", SearchOption.AllDirectories)
|
||||
.Select(path => Path.GetRelativePath(_root, path)
|
||||
.Replace(Path.DirectorySeparatorChar, '/'))
|
||||
.OrderBy(static key => key, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
public void WriteText(string key, string content)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(content);
|
||||
string path = Resolve(key);
|
||||
string directory = Path.GetDirectoryName(path)!;
|
||||
Directory.CreateDirectory(directory);
|
||||
string temporary = Path.Combine(
|
||||
directory,
|
||||
$".{Path.GetFileName(path)}.{Guid.NewGuid():N}.tmp");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(temporary, content, new UTF8Encoding(false));
|
||||
File.Move(temporary, path, overwrite: true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(temporary))
|
||||
File.Delete(temporary);
|
||||
}
|
||||
}
|
||||
|
||||
public bool Delete(string key)
|
||||
{
|
||||
string path = Resolve(key);
|
||||
if (!File.Exists(path))
|
||||
return false;
|
||||
File.Delete(path);
|
||||
return true;
|
||||
}
|
||||
|
||||
private string Resolve(string key)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(key);
|
||||
if (Path.IsPathRooted(key))
|
||||
throw new ArgumentException("Plugin storage keys must be relative.", nameof(key));
|
||||
string path = Path.GetFullPath(Path.Combine(_root, key));
|
||||
string relative = Path.GetRelativePath(_root, path);
|
||||
if (Path.IsPathRooted(relative)
|
||||
|| relative.Equals("..", StringComparison.Ordinal)
|
||||
|| relative.StartsWith(
|
||||
".." + Path.DirectorySeparatorChar,
|
||||
StringComparison.Ordinal))
|
||||
{
|
||||
throw new ArgumentException("Plugin storage key escapes its root.", nameof(key));
|
||||
}
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
|
@ -39,17 +39,20 @@ internal sealed class HeadlessPluginHost
|
|||
internal HeadlessPluginHost(
|
||||
GameRuntime runtime,
|
||||
IPluginLogger logger,
|
||||
IPluginCommandRegistry? commands = null)
|
||||
IPluginCommandRegistry? commands = null,
|
||||
IPluginStorage? vtankProfiles = null)
|
||||
{
|
||||
_runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
|
||||
Log = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
Commands = commands ?? NoOpPluginCommandRegistry.Instance;
|
||||
VtankProfiles = vtankProfiles ?? NoOpPluginStorage.Instance;
|
||||
_eventSubscription = runtime.Subscribe(this);
|
||||
}
|
||||
|
||||
public bool HasUi => false;
|
||||
public IPluginLogger Log { get; }
|
||||
public IPluginCommandRegistry Commands { get; }
|
||||
public IPluginStorage VtankProfiles { get; }
|
||||
public IGameState State => this;
|
||||
public IEvents Events => this;
|
||||
public ISelectionService Selection => _runtime.ActionOwner.Selection;
|
||||
|
|
|
|||
|
|
@ -45,7 +45,8 @@ internal sealed class HeadlessPluginSession : IDisposable
|
|||
string sessionId,
|
||||
IEnumerable<string> roots,
|
||||
IReadOnlyList<string>? allowList,
|
||||
IPluginCommandRegistry? commands = null)
|
||||
IPluginCommandRegistry? commands = null,
|
||||
IPluginStorage? vtankProfiles = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(runtime);
|
||||
ArgumentNullException.ThrowIfNull(diagnostics);
|
||||
|
|
@ -59,7 +60,8 @@ internal sealed class HeadlessPluginSession : IDisposable
|
|||
diagnostics,
|
||||
sessionId,
|
||||
() => runtime.Generation.Value),
|
||||
commands);
|
||||
commands,
|
||||
vtankProfiles);
|
||||
var plugins = new PluginSession(
|
||||
host,
|
||||
status => Report(statusWriter, sessionId, status),
|
||||
|
|
|
|||
|
|
@ -42,4 +42,22 @@ public interface IPluginHost
|
|||
/// host kind.
|
||||
/// </summary>
|
||||
IAutomationSurface Automation { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Storage rooted at the VTank profile folder (real <c>.usd</c>/
|
||||
/// <c>.ast</c>/<c>.af</c> files, VTank's own naming rules) rather than
|
||||
/// this plugin's own scoped <see cref="Storage"/> directory — see
|
||||
/// <c>AcDream.Plugins.MossTank.VtankProfileDirectory</c>, which
|
||||
/// enumerates through this property exclusively (no <c>System.IO</c>,
|
||||
/// no per-OS portable-default fallback of its own) so directory
|
||||
/// discovery stays entirely host-composed. Defaults to the inert
|
||||
/// <see cref="NoOpPluginStorage"/> (<c>IsAvailable</c> false) when the
|
||||
/// host has no opinion. A graphical host may root this at a real
|
||||
/// installed VTank's own profile directory for direct interop, or at
|
||||
/// its own portable per-OS default under
|
||||
/// <c>ApplicationPathSet.DataDirectory</c>; that discovery belongs
|
||||
/// entirely to the host composing this property, never to the plugin
|
||||
/// reading it.
|
||||
/// </summary>
|
||||
IPluginStorage VtankProfiles => NoOpPluginStorage.Instance;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,5 +24,6 @@
|
|||
</None>
|
||||
<EmbeddedResource Include="VtankCraftRecipes.tsv" />
|
||||
<EmbeddedResource Include="VtankAmmunitionOptions.tsv" />
|
||||
<EmbeddedResource Include="VtankDefaultSettings.usd" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
|
|
|||
|
|
@ -174,11 +174,11 @@ internal sealed class AttackSpellCatalog
|
|||
{
|
||||
if (shape == AttackSpellShape.Streak)
|
||||
return 1;
|
||||
if (settings.UseArcs && target.Distance >= settings.ArcRange)
|
||||
if (ShouldUseArc(settings, target))
|
||||
return shape == AttackSpellShape.Arc ? 2 : 3;
|
||||
return shape == AttackSpellShape.Direct ? 2 : 3;
|
||||
}
|
||||
if (settings.UseArcs && target.Distance >= settings.ArcRange)
|
||||
if (ShouldUseArc(settings, target))
|
||||
{
|
||||
if (shape == AttackSpellShape.Arc)
|
||||
return 1;
|
||||
|
|
@ -203,6 +203,19 @@ internal sealed class AttackSpellCatalog
|
|||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// VTank's real 3-way UseArcs switch (<c>refs/vtank/decompiled/hi.cs:515-538</c>):
|
||||
/// <c>No</c> never arcs, <c>Yes</c> always arcs, <c>AtRange</c> arcs only
|
||||
/// once the target is at or beyond <see cref="CombatSettings.ArcRange"/>.
|
||||
/// </summary>
|
||||
private static bool ShouldUseArc(CombatSettings settings, PluginCombatTarget target) =>
|
||||
settings.UseArcs switch
|
||||
{
|
||||
UseArcsMode.Yes => true,
|
||||
UseArcsMode.AtRange => target.Distance >= settings.ArcRange,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
private static bool MatchesPrimaryShape(
|
||||
AttackSpellShape shape,
|
||||
MonsterRuleActions actions,
|
||||
|
|
|
|||
|
|
@ -243,11 +243,11 @@ internal sealed class CombatController
|
|||
_untilScan -= Math.Max(0d, elapsedSeconds);
|
||||
if (_untilScan <= 0d)
|
||||
{
|
||||
float acquisitionRange = navigationEnabled
|
||||
double acquisitionRange = navigationEnabled
|
||||
? Math.Max(_settings.MaximumRange, _settings.ApproachDistance)
|
||||
: _settings.MaximumRange;
|
||||
_targets = _host.Automation.Combat.CaptureHostileTargets(
|
||||
acquisitionRange);
|
||||
(float)acquisitionRange);
|
||||
foreach (uint ghost in _failures.ObserveTargets(
|
||||
_targets,
|
||||
_now,
|
||||
|
|
@ -588,7 +588,7 @@ internal sealed class CombatController
|
|||
|
||||
float maximumRange = spell.BaseRangeConstant
|
||||
+ (spell.BaseRangeModifier * skill.Current)
|
||||
- _settings.SpellRangeFudge;
|
||||
- (float)_settings.SpellRangeFudge;
|
||||
return maximumRange <= 0f
|
||||
|| target.ObjectId == 0u
|
||||
|| target.Distance <= MathF.Min(75f, maximumRange);
|
||||
|
|
@ -1087,15 +1087,15 @@ internal sealed class CombatController
|
|||
targetObjectId,
|
||||
kind,
|
||||
height,
|
||||
_settings.CollisionProjectileRadius,
|
||||
_settings.CollisionStepDistance,
|
||||
(float)_settings.CollisionProjectileRadius,
|
||||
(float)_settings.CollisionStepDistance,
|
||||
_settings.MaximumCollisionChecksPerTick)
|
||||
: _host.Automation.Projectiles.EvaluatePath(
|
||||
targetObjectId,
|
||||
kind,
|
||||
height,
|
||||
_settings.CollisionProjectileRadius,
|
||||
_settings.CollisionStepDistance,
|
||||
(float)_settings.CollisionProjectileRadius,
|
||||
(float)_settings.CollisionStepDistance,
|
||||
_settings.MaximumCollisionChecksPerTick);
|
||||
if (_settings.ShowCollisionDebug && result.DebugSamples.Count > 0)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -22,6 +22,23 @@ internal enum DebuffSelectionMethod
|
|||
Skill = 2,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// VTank's real 3-way "UseArcs" enum
|
||||
/// (<c>refs/vtank/decompiled/hi.cs:515-538</c>, switch on
|
||||
/// <c>f3.f("UseArcs")</c>): <c>No</c> always picks the direct-shape spell,
|
||||
/// <c>AtRange</c> picks the arc shape only once the target is at or beyond
|
||||
/// <c>ArcRange</c>, and <c>Yes</c> always picks the arc shape regardless of
|
||||
/// distance. A prior port collapsed this onto a single bool (effectively
|
||||
/// only distinguishing "No" from "AtRange"), which cannot represent "Yes"
|
||||
/// at all — see <see cref="AttackSpellCatalog"/>.
|
||||
/// </summary>
|
||||
internal enum UseArcsMode
|
||||
{
|
||||
No = 1,
|
||||
AtRange = 2,
|
||||
Yes = 3,
|
||||
}
|
||||
|
||||
internal enum PetRangeMode
|
||||
{
|
||||
AttackDistance = 0,
|
||||
|
|
@ -52,25 +69,30 @@ internal sealed class CombatSettings
|
|||
public bool Enabled { get; set; } = true;
|
||||
/// <summary>VTank's hunt-cast skill margin.</summary>
|
||||
public int HuntSkillExcessOverDifficulty { get; set; } = 25;
|
||||
public float MaximumRange { get; set; } = 5f;
|
||||
// Declared tDouble in VTank's own Settings table (VtankOptionCatalog):
|
||||
// double, not float, so a loaded .usd round-trips this setting exactly
|
||||
// instead of losing precision below float's ~7-digit guarantee (item I,
|
||||
// slice-1 fix round). Physics/combat consumers that want a float cast
|
||||
// at their own use site.
|
||||
public double MaximumRange { get; set; } = 5d;
|
||||
/// <summary>
|
||||
/// Monsters nearer than this are not valid attack targets. VTank applies
|
||||
/// this before priority and angle/range ranking.
|
||||
/// </summary>
|
||||
public float MinimumRange { get; set; }
|
||||
public double MinimumRange { get; set; }
|
||||
/// <summary>
|
||||
/// VTank's Approach Distance. Zero disables monster approach; otherwise
|
||||
/// navigation may close a selected target from this range down to
|
||||
/// <see cref="MaximumRange"/>.
|
||||
/// </summary>
|
||||
public float ApproachDistance { get; set; }
|
||||
public double ApproachDistance { get; set; }
|
||||
public bool IdlePeaceMode { get; set; }
|
||||
public bool StopMacroOnDeath { get; set; } = true;
|
||||
public bool JumpOutWandCasting { get; set; }
|
||||
public bool DoJiggle { get; set; }
|
||||
public TargetSelectionMethod SelectionMethod { get; set; } =
|
||||
TargetSelectionMethod.Both;
|
||||
public float TargetSelectAngleRange { get; set; } = 5f;
|
||||
public double TargetSelectAngleRange { get; set; } = 5d;
|
||||
public bool TargetLock { get; set; }
|
||||
public PluginAttackHeight AttackHeight { get; set; } =
|
||||
PluginAttackHeight.Medium;
|
||||
|
|
@ -83,16 +105,16 @@ internal sealed class CombatSettings
|
|||
DebuffSelectionMethod.Skill;
|
||||
public double DebuffPrecastSeconds { get; set; } = 5d;
|
||||
public bool SwitchWandsToDebuff { get; set; }
|
||||
public bool UseArcs { get; set; } = true;
|
||||
public float SpellRangeFudge { get; set; } = 1f;
|
||||
public UseArcsMode UseArcs { get; set; } = UseArcsMode.AtRange;
|
||||
public double SpellRangeFudge { get; set; } = 1d;
|
||||
public bool UseBreakableTurnTo { get; set; } = true;
|
||||
public bool UseProjectileAwareness { get; set; } = true;
|
||||
public float CollisionProjectileRadius { get; set; } = 0.4f;
|
||||
public float CollisionStepDistance { get; set; } = 0.7f;
|
||||
public double CollisionProjectileRadius { get; set; } = 0.4d;
|
||||
public double CollisionStepDistance { get; set; } = 0.7d;
|
||||
public bool ShowCollisionDebug { get; set; }
|
||||
public int MaximumCollisionChecksPerTick { get; set; } = 500;
|
||||
public float ArcRange { get; set; } = 5f;
|
||||
public float RingDistance { get; set; } = 5f;
|
||||
public double ArcRange { get; set; } = 5d;
|
||||
public double RingDistance { get; set; } = 5d;
|
||||
public int MinimumRingTargets { get; set; } = 4;
|
||||
public bool DeleteGhostMonsters { get; set; } = true;
|
||||
public int GhostMonsterSpellAttemptCount { get; set; } = 200;
|
||||
|
|
@ -102,7 +124,7 @@ internal sealed class CombatSettings
|
|||
public double GhostDeleteHealthTrackerSeconds { get; set; } = 30d;
|
||||
public bool SummonPets { get; set; } = true;
|
||||
public PetRangeMode PetRangeMode { get; set; } = PetRangeMode.AttackDistance;
|
||||
public float PetCustomRange { get; set; } = 5f;
|
||||
public double PetCustomRange { get; set; } = 5d;
|
||||
public int PetMonsterDensity { get; set; } = 1;
|
||||
public int PetRefillCountIdle { get; set; } = 3;
|
||||
public int PetRefillCountNormal { get; set; } = 1;
|
||||
|
|
|
|||
|
|
@ -89,8 +89,10 @@ internal sealed class LootSettings
|
|||
public bool CombineSalvage { get; set; } = true;
|
||||
public int ManaStoneLootCount { get; set; } = 4;
|
||||
public int ManaTankMinimumMana { get; set; } = 1000;
|
||||
public float CorpseApproachRange { get; set; } = 40f;
|
||||
public float CorpseMinimumApproachRange { get; set; } = 3.36f;
|
||||
// Declared tDouble in VTank's own Settings table (item I, slice-1 fix
|
||||
// round) — see CombatSettings.MaximumRange's doc comment for why.
|
||||
public double CorpseApproachRange { get; set; } = 40d;
|
||||
public double CorpseMinimumApproachRange { get; set; } = 3.36d;
|
||||
public double CorpseOpenTimeoutSeconds { get; set; } = 1.5d;
|
||||
public double CorpseItemAppearanceTimeoutSeconds { get; set; } = 6d;
|
||||
public double CorpseItemIdentifyTimeoutSeconds { get; set; } = 60d;
|
||||
|
|
@ -524,7 +526,7 @@ internal sealed class LootController
|
|||
_scanRemaining = Math.Clamp(_settings.ScanIntervalSeconds, 0.05d, 5d);
|
||||
|
||||
IReadOnlyList<PluginLootContainer> corpses = loot.CaptureCorpses(
|
||||
Math.Clamp(_settings.CorpseApproachRange, 2f, 100f));
|
||||
(float)Math.Clamp(_settings.CorpseApproachRange, 2d, 100d));
|
||||
PruneCorpseCache();
|
||||
foreach (PluginLootContainer seen in corpses)
|
||||
_corpseFirstSeen.TryAdd(seen.ObjectId, _lifetime);
|
||||
|
|
|
|||
|
|
@ -77,6 +77,17 @@ internal sealed class MetaAction
|
|||
public double Number { get; set; }
|
||||
public double SecondaryNumber { get; set; }
|
||||
public List<MetaAction> Children { get; set; } = [];
|
||||
/// <summary>
|
||||
/// The parsed route for a <see cref="MetaActionKind.LoadEmbeddedNavigationRoute"/>
|
||||
/// action. Replaces a prior port's binary "uTank2 NAV 1.2" text blob
|
||||
/// carried in <see cref="Text"/>: both the <c>.af</c> importer
|
||||
/// (<c>MetafSerializer.ResolveEmbeddedNavs</c>) and the <c>.met</c>
|
||||
/// importer (<c>VtankMetaProfileSerializer.ReadEmbeddedNavigation</c>)
|
||||
/// now produce this typed model directly, and
|
||||
/// <c>MetaEngine.LoadEmbeddedNavigationRoute</c> consumes it without a
|
||||
/// re-parse. Null only for an unresolved/never-defined tag.
|
||||
/// </summary>
|
||||
public NavigationSettings? EmbeddedRoute { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class MetaRule
|
||||
|
|
@ -102,7 +113,7 @@ internal sealed class MetaServices
|
|||
static () => double.PositiveInfinity;
|
||||
public Func<int, double, int> CountMonstersByPriority { get; init; } =
|
||||
static (_, _) => 0;
|
||||
public Action<string> LoadEmbeddedNavigationRoute { get; init; } = static _ => { };
|
||||
public Action<NavigationSettings?> LoadEmbeddedNavigationRoute { get; init; } = static _ => { };
|
||||
public Func<string, ExpressionValue> GetOption { get; init; } =
|
||||
static _ => ExpressionValue.Zero;
|
||||
public Func<string, ExpressionValue, bool> SetOption { get; init; } =
|
||||
|
|
@ -379,7 +390,7 @@ internal sealed class MetaEngine
|
|||
}
|
||||
return true;
|
||||
case MetaActionKind.LoadEmbeddedNavigationRoute:
|
||||
_services.LoadEmbeddedNavigationRoute(action.Text);
|
||||
_services.LoadEmbeddedNavigationRoute(action.EmbeddedRoute);
|
||||
return true;
|
||||
case MetaActionKind.CallMetaState:
|
||||
if (_callStack.Count >= MaximumCallDepth)
|
||||
|
|
|
|||
1332
src/AcDream.Plugins.MossTank/MetafSerializer.cs
Normal file
1332
src/AcDream.Plugins.MossTank/MetafSerializer.cs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -241,10 +241,7 @@ internal sealed partial class MossTankPanel
|
|||
if (_profiles.Create(
|
||||
name,
|
||||
copyCurrent: true,
|
||||
_combatSettings,
|
||||
_buffSettings,
|
||||
_vitalSettings,
|
||||
_inventorySettings,
|
||||
_allSettings,
|
||||
_noBuffItemNames,
|
||||
out string notice))
|
||||
{
|
||||
|
|
@ -271,7 +268,17 @@ internal sealed partial class MossTankPanel
|
|||
WriteVtank("Usage: /vt nav [save/load] [filename]");
|
||||
return;
|
||||
}
|
||||
name = StripExtension(name, ".nav");
|
||||
// Item J (slice-1 fix round): .af is the only VTank-compatible
|
||||
// storage format now (Campaign VT slice 1 Part A); ".nav" is
|
||||
// stripped for legacy-typed names, ".af" for names copy-pasted
|
||||
// from a real .af file (the VtankProfiles directory, or an
|
||||
// imports/ drop-in for TryImportLegacy below) — without both,
|
||||
// "/vt nav save Foo.af" would have produced a doubled "Foo.af.af"
|
||||
// file. Round 3 item 12: this comment previously cited an
|
||||
// "exports/nav/" mirror directory that no longer exists — route
|
||||
// profiles have written directly to their real .af file since the
|
||||
// round 2 step 2-3 cutover, with no separate export copy.
|
||||
name = StripExtension(name, ".nav", ".af");
|
||||
if (operation == "save")
|
||||
{
|
||||
_routeProfiles.Create(
|
||||
|
|
@ -353,7 +360,9 @@ internal sealed partial class MossTankPanel
|
|||
WriteVtank("Usage: /vt meta [save/load] [filename]");
|
||||
return;
|
||||
}
|
||||
name = StripExtension(name, ".met", ".json");
|
||||
// Item J (slice-1 fix round): .af is the only VTank-compatible
|
||||
// storage format now — see the analogous nav-command comment above.
|
||||
name = StripExtension(name, ".met", ".json", ".af");
|
||||
if (operation == "save")
|
||||
{
|
||||
_metaProfiles.Create(
|
||||
|
|
@ -420,19 +429,28 @@ internal sealed partial class MossTankPanel
|
|||
WriteVtank("Option set: Invalid option specified.");
|
||||
return;
|
||||
}
|
||||
if (rawValue.Length == 0 || !TryParseOptionValue(rawValue, out ExpressionValue value))
|
||||
canonical = VtankOptionCatalog.Canonical(name);
|
||||
VtankSettingValueType declaredType = VtankOptionCatalog.DeclaredType(canonical);
|
||||
if (rawValue.Length == 0 || !TryParseOptionValue(rawValue, declaredType, out ExpressionValue value))
|
||||
{
|
||||
WriteVtank("Option set: Invalid value specified.");
|
||||
// Round 3 item 6: retail's exact failure text
|
||||
// (refs/vtank/decompiled/uTank2/PluginCore.cs:5501,5508,5612)
|
||||
// names the option and the CLR type its Settings row
|
||||
// actually declares (gy's own m_a, ToString()'d).
|
||||
WriteVtank(
|
||||
"Option set: Invalid value specified. Proper type of "
|
||||
+ canonical + " is " + DeclaredClrTypeName(declaredType) + ".");
|
||||
return;
|
||||
}
|
||||
canonical = VtankOptionCatalog.Canonical(name);
|
||||
SetMetaOption(canonical, value);
|
||||
if (operation.Equals("setinall", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
int count = _profiles.SetOptionInAll(
|
||||
canonical,
|
||||
ToMonsterValue(value));
|
||||
WriteVtank($"Set option {canonical} in {count} profile(s) = {GetMetaOption(canonical).ToDisplayString()}");
|
||||
// Round 3 item 6: retail's exact bk.a text
|
||||
// (refs/vtank/decompiled/bk.cs:32) — no "= value" suffix,
|
||||
// and the count is every .usd file scanned, not just the
|
||||
// ones that already had a row for this name.
|
||||
int count = _profiles.SetOptionInAll(canonical, _allSettings);
|
||||
WriteVtank($"Done saving setting {canonical} to all profiles. (Changed {count} profiles)");
|
||||
}
|
||||
else
|
||||
{
|
||||
|
|
@ -754,9 +772,9 @@ internal sealed partial class MossTankPanel
|
|||
private void TestPet()
|
||||
{
|
||||
IReadOnlyList<PluginCombatTarget> targets = _host.Automation.Combat
|
||||
.CaptureHostileTargets(_combatSettings.PetRangeMode == PetRangeMode.Custom
|
||||
.CaptureHostileTargets((float)(_combatSettings.PetRangeMode == PetRangeMode.Custom
|
||||
? _combatSettings.PetCustomRange
|
||||
: _combatSettings.MaximumRange);
|
||||
: _combatSettings.MaximumRange));
|
||||
PetAutomationChoice choice = PetAutomation.Select(
|
||||
_host.Automation.Items.CaptureOwnedItems(),
|
||||
targets,
|
||||
|
|
@ -962,22 +980,107 @@ internal sealed partial class MossTankPanel
|
|||
return result;
|
||||
}
|
||||
|
||||
private static bool TryParseOptionValue(string source, out ExpressionValue value)
|
||||
/// <summary>
|
||||
/// Free-form parse (no catalog type to check against) for callers like
|
||||
/// the Advanced Options editor, which can target names outside the 137-
|
||||
/// row catalog: best-effort bool, then number, then string.
|
||||
/// </summary>
|
||||
private static bool TryParseOptionValue(string source, out ExpressionValue value) =>
|
||||
TryParseOptionValue(source, declaredType: null, out value);
|
||||
|
||||
/// <summary>
|
||||
/// Round 3 item 6: <c>/vt opt set</c>/<c>setinall</c> validate the typed
|
||||
/// value against the catalog's OWN declared <see cref="VtankSettingValueType"/>
|
||||
/// (retail's <c>eSettingValueType</c>) rather than accepting anything —
|
||||
/// a value that does not parse as that type fails with retail's exact
|
||||
/// "Proper type of X is Y." text (<see cref="HandleOptionCommand"/>).
|
||||
/// <paramref name="declaredType"/> is <see langword="null"/> only for
|
||||
/// the free-form Advanced Options editor, which keeps the original lax
|
||||
/// best-effort behavior.
|
||||
/// </summary>
|
||||
private static bool TryParseOptionValue(
|
||||
string source,
|
||||
VtankSettingValueType? declaredType,
|
||||
out ExpressionValue value)
|
||||
{
|
||||
if (bool.TryParse(source, out bool boolean))
|
||||
if (source.Length == 0)
|
||||
{
|
||||
value = ExpressionValue.Boolean(boolean);
|
||||
return true;
|
||||
value = default;
|
||||
return false;
|
||||
}
|
||||
if (double.TryParse(source, NumberStyles.Float, CultureInfo.InvariantCulture, out double number))
|
||||
switch (declaredType)
|
||||
{
|
||||
value = ExpressionValue.Number(number);
|
||||
return true;
|
||||
case VtankSettingValueType.Bool:
|
||||
if (bool.TryParse(source, out bool boolean))
|
||||
{
|
||||
value = ExpressionValue.Boolean(boolean);
|
||||
return true;
|
||||
}
|
||||
value = default;
|
||||
return false;
|
||||
case VtankSettingValueType.Int:
|
||||
case VtankSettingValueType.Enum:
|
||||
if (int.TryParse(
|
||||
source, NumberStyles.Integer, CultureInfo.InvariantCulture, out int integer))
|
||||
{
|
||||
value = ExpressionValue.Number(integer);
|
||||
return true;
|
||||
}
|
||||
value = default;
|
||||
return false;
|
||||
case VtankSettingValueType.Double:
|
||||
case VtankSettingValueType.Single:
|
||||
if (double.TryParse(
|
||||
source, NumberStyles.Float, CultureInfo.InvariantCulture, out double number))
|
||||
{
|
||||
value = ExpressionValue.Number(number);
|
||||
return true;
|
||||
}
|
||||
value = default;
|
||||
return false;
|
||||
case VtankSettingValueType.String:
|
||||
value = ExpressionValue.String(source);
|
||||
return true;
|
||||
default:
|
||||
// No declared type (free-form editor) or VtankSettingValueType.Custom
|
||||
// (the one non-scalar row, RechargeHandlerSet, never reached
|
||||
// through /vt opt set): keep the original lax parse.
|
||||
if (bool.TryParse(source, out bool freeBoolean))
|
||||
{
|
||||
value = ExpressionValue.Boolean(freeBoolean);
|
||||
return true;
|
||||
}
|
||||
if (double.TryParse(
|
||||
source, NumberStyles.Float, CultureInfo.InvariantCulture, out double freeNumber))
|
||||
{
|
||||
value = ExpressionValue.Number(freeNumber);
|
||||
return true;
|
||||
}
|
||||
value = ExpressionValue.String(source);
|
||||
return true;
|
||||
}
|
||||
value = ExpressionValue.String(source);
|
||||
return source.Length != 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The CLR type name retail's exact failure text names
|
||||
/// (<c>refs/vtank/decompiled/uTank2/PluginCore.cs:5501,5508,5612</c>:
|
||||
/// <c>cw2[1].b().ToString()</c> — the Settings row's underlying
|
||||
/// <c>gy</c> cell type, <see cref="Type.ToString"/>'d). Retail stores an
|
||||
/// Enum-declared row as a plain int cell (confirmed against the
|
||||
/// UseArcs row in the shipped defaultsettings.usd fixture), so it also
|
||||
/// reads "System.Int32".
|
||||
/// </summary>
|
||||
private static string DeclaredClrTypeName(VtankSettingValueType type) => type switch
|
||||
{
|
||||
VtankSettingValueType.Bool => "System.Boolean",
|
||||
VtankSettingValueType.Double => "System.Double",
|
||||
VtankSettingValueType.Int => "System.Int32",
|
||||
VtankSettingValueType.Single => "System.Single",
|
||||
VtankSettingValueType.String => "System.String",
|
||||
VtankSettingValueType.Enum => "System.Int32",
|
||||
_ => "System.Object",
|
||||
};
|
||||
|
||||
private bool TryParseCoordinates(string source, out PluginNavigationPosition position)
|
||||
{
|
||||
position = default;
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
|
@ -6,13 +7,32 @@ using AcDream.Plugin.Abstractions;
|
|||
namespace AcDream.Plugins.MossTank;
|
||||
|
||||
/// <summary>
|
||||
/// Independent VTank loot-profile lifecycle. Macro settings select this
|
||||
/// profile by character, but its ordered rules live in their own document.
|
||||
/// VTank-compatible loot profile lifecycle. <c>.utl</c> (VTClassic's real
|
||||
/// public format, <see cref="VtankLootProfileSerializer"/>) is the ONLY
|
||||
/// storage/authoring format — round 3 item 9 cutover, matching the
|
||||
/// Settings/.usd, Route/.af, and Meta/.af cutovers already landed. Real
|
||||
/// VTank's own loot picker (<c>aa()</c>, <c>uTank2/PluginCore.cs:7127-7154</c>)
|
||||
/// seeds ONLY <c>[None]</c> — there is no per-character auto loot file and
|
||||
/// no "mine only" filter for loot at all
|
||||
/// (<c>docs/research/vtank-kb/01-settings-and-profiles.md</c> section 3).
|
||||
/// MossTank keeps its own established <see cref="ByCharacter"/> convention
|
||||
/// (already load-bearing for the other three stores) as a recorded
|
||||
/// adaptation rather than a retail behavior.
|
||||
/// </summary>
|
||||
internal sealed class MossTankLootProfileStore
|
||||
{
|
||||
public const string ByCharacter = "By char";
|
||||
private const string IndexKey = "profiles/loot/index.json";
|
||||
|
||||
// The pre-cutover roster (round 3 item 9): before this cutover, this
|
||||
// key was the LIVE index (Names + SelectedByCharacter) — unlike Meta/
|
||||
// Route's already-abandoned pre-round-2 rosters, loot's roster was
|
||||
// still actively read/written until this exact commit. Read-only now,
|
||||
// consulted ONLY by the one-time SweepLegacyRosterIfNeeded (which also
|
||||
// converts the SELECTED profile, so there is no separate
|
||||
// MigrateLegacyIfNeeded here the way Settings/Meta/Route each have —
|
||||
// the sweep already covers every named profile, selected or not).
|
||||
private const string LegacyRosterKey = "profiles/loot/index.json";
|
||||
|
||||
private static readonly JsonSerializerOptions Options = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
|
|
@ -20,62 +40,89 @@ internal sealed class MossTankLootProfileStore
|
|||
};
|
||||
|
||||
private readonly IPluginHost _host;
|
||||
private IndexDocument _index;
|
||||
private string _characterName = string.Empty;
|
||||
private string _selected = ByCharacter;
|
||||
private bool _rosterSwept;
|
||||
|
||||
public MossTankLootProfileStore(IPluginHost host)
|
||||
{
|
||||
_host = host ?? throw new ArgumentNullException(nameof(host));
|
||||
_index = Read<IndexDocument>(IndexKey) ?? new IndexDocument();
|
||||
_index.Names ??= [];
|
||||
_index.SelectedByCharacter = new Dictionary<string, string>(
|
||||
_index.SelectedByCharacter ?? new Dictionary<string, string>(),
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public string Selected => _selected;
|
||||
/// <summary>
|
||||
/// The bare name a user typed to select/create this profile (the file
|
||||
/// name minus its <c>.utl</c> extension) — internal identity
|
||||
/// (<see cref="_selected"/>) always carries the real, on-disk file
|
||||
/// name; only display strips it, matching
|
||||
/// <see cref="MossTankMetaProfileStore.Selected"/>'s own convention.
|
||||
/// </summary>
|
||||
public string Selected => StripUtl(_selected);
|
||||
public string? RecoveryNotice { get; private set; }
|
||||
public IReadOnlyList<string> AvailableNames => new[] { ByCharacter }
|
||||
.Concat(_index.Names)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(name => name.Equals(
|
||||
ByCharacter,
|
||||
StringComparison.OrdinalIgnoreCase) ? 0 : 1)
|
||||
.ThenBy(static name => name, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
private static string StripUtl(string name) => name.Equals(
|
||||
ByCharacter, StringComparison.OrdinalIgnoreCase)
|
||||
? name
|
||||
: name.EndsWith(".utl", StringComparison.OrdinalIgnoreCase)
|
||||
? name[..^4]
|
||||
: name;
|
||||
|
||||
private string Server => _host.Automation.Character.WorldName;
|
||||
private IPluginStorage VtankStorage => _host.VtankProfiles;
|
||||
private bool CanBindFiles => _characterName.Length > 0 && Server.Length > 0;
|
||||
|
||||
public IReadOnlyList<string> AvailableNames
|
||||
{
|
||||
get
|
||||
{
|
||||
var names = new List<string> { ByCharacter };
|
||||
foreach (VtankProfileDirectory.ProfileEntry entry in
|
||||
VtankProfileDirectory.ListLootProfiles(VtankStorage))
|
||||
{
|
||||
if (entry.FileName.Length == 0)
|
||||
continue; // VTank's own "[None]"/MossTank's "[By char]" sentinels.
|
||||
names.Add(StripUtl(entry.FileName));
|
||||
}
|
||||
return names;
|
||||
}
|
||||
}
|
||||
|
||||
public bool BindCharacter(string? characterName)
|
||||
{
|
||||
string normalized = string.IsNullOrWhiteSpace(characterName)
|
||||
? string.Empty
|
||||
: characterName.Trim();
|
||||
if (string.Equals(
|
||||
normalized,
|
||||
_characterName,
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (string.Equals(normalized, _characterName, StringComparison.OrdinalIgnoreCase))
|
||||
return false;
|
||||
}
|
||||
_characterName = normalized;
|
||||
_selected = _index.SelectedByCharacter.TryGetValue(
|
||||
SelectionKey(),
|
||||
out string? selected)
|
||||
&& IsKnown(selected)
|
||||
? CanonicalName(selected)
|
||||
: ByCharacter;
|
||||
VtankProfileDirectory.VtankCharacterBinding? binding = CanBindFiles
|
||||
? VtankProfileDirectory.TryReadCharacterBinding(VtankStorage, _characterName, Server)
|
||||
: null;
|
||||
_selected = binding is { LootFileName.Length: > 0 } bound
|
||||
? bound.LootFileName
|
||||
: ByCharacter;
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool Select(string? name)
|
||||
{
|
||||
string normalized = name?.Trim() ?? string.Empty;
|
||||
if (!IsKnown(normalized))
|
||||
if (normalized.Length == 0)
|
||||
return false;
|
||||
_selected = CanonicalName(normalized);
|
||||
_index.SelectedByCharacter[SelectionKey()] = _selected;
|
||||
SaveIndex();
|
||||
return true;
|
||||
if (normalized.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_selected = ByCharacter;
|
||||
WriteBinding();
|
||||
return true;
|
||||
}
|
||||
|
||||
string candidate = ToFileName(normalized);
|
||||
if (VtankStorage.IsAvailable && VtankStorage.ReadText(candidate) is not null)
|
||||
{
|
||||
_selected = candidate;
|
||||
WriteBinding();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool Create(
|
||||
|
|
@ -97,32 +144,17 @@ internal sealed class MossTankLootProfileStore
|
|||
return false;
|
||||
}
|
||||
|
||||
LootProfileDocument? currentDocument = copyCurrent
|
||||
? Read<LootProfileDocument>(CurrentKey())
|
||||
: null;
|
||||
var document = new LootProfileDocument
|
||||
string fileName = ToFileName(normalized);
|
||||
var profile = new VtankLootProfile
|
||||
{
|
||||
Rules = copyCurrent
|
||||
? current.Select(LootRuleDocument.From).ToArray()
|
||||
: [],
|
||||
Rules = copyCurrent ? current.ToList() : [],
|
||||
SalvageCombine = copyCurrent
|
||||
? (settings?.SalvageCombine.Clone()
|
||||
?? currentDocument?.SalvageCombine?.Clone()
|
||||
?? new VtankSalvageCombineSettings())
|
||||
? settings?.SalvageCombine.Clone() ?? new VtankSalvageCombineSettings()
|
||||
: new VtankSalvageCombineSettings(),
|
||||
UnknownBlocks = copyCurrent
|
||||
? currentDocument?.UnknownBlocks ?? []
|
||||
: [],
|
||||
};
|
||||
Write(ProfileKey(normalized, byCharacter: false), document);
|
||||
if (!_index.Names.Contains(normalized, StringComparer.OrdinalIgnoreCase))
|
||||
_index.Names.Add(normalized);
|
||||
_selected = _index.Names.First(entry => entry.Equals(
|
||||
normalized,
|
||||
StringComparison.OrdinalIgnoreCase));
|
||||
_index.SelectedByCharacter[SelectionKey()] = _selected;
|
||||
SaveIndex();
|
||||
WriteLegacyExport(_selected, document);
|
||||
WriteUtl(fileName, profile);
|
||||
_selected = fileName;
|
||||
WriteBinding();
|
||||
notice = copyCurrent
|
||||
? $"Copied loot rules to {_selected}."
|
||||
: $"Created loot profile {_selected}.";
|
||||
|
|
@ -133,18 +165,23 @@ internal sealed class MossTankLootProfileStore
|
|||
public bool LoadCurrent(List<LootRule> target, LootSettings? settings = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(target);
|
||||
LootProfileDocument? document = Read<LootProfileDocument>(CurrentKey());
|
||||
if (document is null)
|
||||
SweepLegacyRosterIfNeeded();
|
||||
string fileName = CurrentFileName();
|
||||
string? text = VtankStorage.IsAvailable ? VtankStorage.ReadText(fileName) : null;
|
||||
if (text is null)
|
||||
return false;
|
||||
target.Clear();
|
||||
foreach (LootRuleDocument rule in document.Rules ?? [])
|
||||
target.Add(rule.ToRule());
|
||||
if (settings is not null)
|
||||
if (!VtankLootProfileSerializer.TryRead(text, out VtankLootProfile profile, out string error))
|
||||
{
|
||||
settings.SalvageCombine =
|
||||
document.SalvageCombine?.Clone()
|
||||
?? new VtankSalvageCombineSettings();
|
||||
RecoveryNotice = MossTankProfileRecovery.Preserve(
|
||||
_host, "loot", fileName, text, new FormatException(error));
|
||||
_host.Log.Warn(RecoveryNotice);
|
||||
return false;
|
||||
}
|
||||
ApplyMossTankExpressions(profile);
|
||||
target.Clear();
|
||||
target.AddRange(profile.Rules);
|
||||
if (settings is not null)
|
||||
settings.SalvageCombine = profile.SalvageCombine.Clone();
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -160,20 +197,22 @@ internal sealed class MossTankLootProfileStore
|
|||
string normalized = name?.Trim() ?? string.Empty;
|
||||
if (normalized.EndsWith(".utl", StringComparison.OrdinalIgnoreCase))
|
||||
normalized = normalized[..^4];
|
||||
if (!IsKnown(normalized))
|
||||
if (normalized.Length == 0)
|
||||
return false;
|
||||
|
||||
string canonical = CanonicalName(normalized);
|
||||
string key = canonical.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase)
|
||||
? ProfileKey(_characterName, byCharacter: true)
|
||||
: ProfileKey(canonical, byCharacter: false);
|
||||
LootProfileDocument? document = Read<LootProfileDocument>(key);
|
||||
if (document is null)
|
||||
string fileName = normalized.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase)
|
||||
? CurrentFileName()
|
||||
: ToFileName(normalized);
|
||||
string? text = VtankStorage.IsAvailable ? VtankStorage.ReadText(fileName) : null;
|
||||
if (text is null || !VtankLootProfileSerializer.TryRead(
|
||||
text, out VtankLootProfile profile, out _))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
ApplyMossTankExpressions(profile);
|
||||
target.Clear();
|
||||
foreach (LootRuleDocument rule in document.Rules ?? [])
|
||||
target.Add(rule.ToRule());
|
||||
target.AddRange(profile.Rules);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -181,17 +220,45 @@ internal sealed class MossTankLootProfileStore
|
|||
IReadOnlyList<LootRule> rules,
|
||||
LootSettings? settings = null)
|
||||
{
|
||||
LootProfileDocument? existing = Read<LootProfileDocument>(CurrentKey());
|
||||
var document = new LootProfileDocument
|
||||
string fileName = CurrentFileName();
|
||||
var profile = new VtankLootProfile
|
||||
{
|
||||
Rules = rules.Select(LootRuleDocument.From).ToArray(),
|
||||
SalvageCombine = settings?.SalvageCombine.Clone()
|
||||
?? existing?.SalvageCombine?.Clone()
|
||||
?? new VtankSalvageCombineSettings(),
|
||||
UnknownBlocks = existing?.UnknownBlocks ?? [],
|
||||
Rules = rules.ToList(),
|
||||
SalvageCombine = settings?.SalvageCombine.Clone() ?? new VtankSalvageCombineSettings(),
|
||||
};
|
||||
Write(CurrentKey(), document);
|
||||
WriteLegacyExport(LegacyProfileName(), document);
|
||||
// Preserve UnknownBlocks/SourceVersion from whatever is already on
|
||||
// disk (a drop-in real .utl may carry blocks MossTank does not
|
||||
// understand yet) rather than discarding them on every save.
|
||||
string? existingText = VtankStorage.IsAvailable ? VtankStorage.ReadText(fileName) : null;
|
||||
if (existingText is not null
|
||||
&& VtankLootProfileSerializer.TryRead(existingText, out VtankLootProfile existing, out _))
|
||||
{
|
||||
profile.UnknownBlocks = existing.UnknownBlocks;
|
||||
}
|
||||
WriteUtl(fileName, profile);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Round 3 item 10: deletes the currently selected named loot profile's
|
||||
/// real <c>.utl</c> file, then falls back to <see cref="ByCharacter"/>.
|
||||
/// Refuses for <see cref="ByCharacter"/> itself — there is no file to
|
||||
/// delete, only a reset (see <see cref="ClearCurrent"/>), matching
|
||||
/// <see cref="MossTankProfileStore.Delete"/>'s own contract.
|
||||
/// </summary>
|
||||
public bool Delete(out string notice)
|
||||
{
|
||||
if (_selected.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
notice = "'By char' is the built-in loot profile and cannot be deleted.";
|
||||
return false;
|
||||
}
|
||||
string fileName = _selected;
|
||||
if (VtankStorage.IsAvailable)
|
||||
VtankStorage.Delete(fileName);
|
||||
_selected = ByCharacter;
|
||||
WriteBinding();
|
||||
notice = $"Deleted loot profile {StripUtl(fileName)}.";
|
||||
return true;
|
||||
}
|
||||
|
||||
public void ClearCurrent(List<LootRule> target, LootSettings? settings = null)
|
||||
|
|
@ -239,47 +306,271 @@ internal sealed class MossTankLootProfileStore
|
|||
return false;
|
||||
}
|
||||
|
||||
var document = new LootProfileDocument
|
||||
{
|
||||
Rules = imported.Rules.Select(LootRuleDocument.From).ToArray(),
|
||||
SalvageCombine = imported.SalvageCombine.Clone(),
|
||||
UnknownBlocks = imported.UnknownBlocks.Select(
|
||||
VtankLootExtraBlockDocument.From).ToArray(),
|
||||
};
|
||||
Write(ProfileKey(normalized, byCharacter: false), document);
|
||||
if (!_index.Names.Contains(normalized, StringComparer.OrdinalIgnoreCase))
|
||||
_index.Names.Add(normalized);
|
||||
_selected = _index.Names.First(entry => entry.Equals(
|
||||
normalized,
|
||||
StringComparison.OrdinalIgnoreCase));
|
||||
_index.SelectedByCharacter[SelectionKey()] = _selected;
|
||||
SaveIndex();
|
||||
string fileName = ToFileName(normalized);
|
||||
WriteUtl(fileName, imported);
|
||||
_selected = fileName;
|
||||
WriteBinding();
|
||||
target.Clear();
|
||||
target.AddRange(imported.Rules);
|
||||
if (settings is not null)
|
||||
settings.SalvageCombine = imported.SalvageCombine.Clone();
|
||||
WriteLegacyExport(_selected, document);
|
||||
notice = $"Imported VTClassic loot profile {_selected}.";
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool IsKnown(string? name) => name is not null
|
||||
&& (name.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase)
|
||||
|| _index.Names.Contains(name, StringComparer.OrdinalIgnoreCase));
|
||||
// ------------------------------------------------------------------
|
||||
// Legacy JSON -> .utl migration (round 3 item 9).
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
private string CanonicalName(string name) => name.Equals(
|
||||
ByCharacter,
|
||||
StringComparison.OrdinalIgnoreCase)
|
||||
? ByCharacter
|
||||
: _index.Names.First(entry => entry.Equals(
|
||||
name,
|
||||
StringComparison.OrdinalIgnoreCase));
|
||||
/// <summary>
|
||||
/// Round 3 item 9: a ONE-TIME sweep (guarded by <see cref="_rosterSwept"/>)
|
||||
/// over the pre-cutover roster at <see cref="LegacyRosterKey"/>. Unlike
|
||||
/// Settings/Meta/Route's own per-selection migration (which only ever
|
||||
/// converted whichever ONE profile happened to be selected), this
|
||||
/// single pass converts EVERY named profile the roster lists AND this
|
||||
/// character's own "By char" JSON document, since the roster was still
|
||||
/// the live mechanism right up to this cutover — there is no separate
|
||||
/// "already migrated one, sweep the rest" split here.
|
||||
/// </summary>
|
||||
private void SweepLegacyRosterIfNeeded()
|
||||
{
|
||||
if (_rosterSwept)
|
||||
return;
|
||||
_rosterSwept = true;
|
||||
if (!_host.Storage.IsAvailable)
|
||||
return;
|
||||
|
||||
private string CurrentKey() => _selected.Equals(
|
||||
ByCharacter,
|
||||
StringComparison.OrdinalIgnoreCase)
|
||||
? ProfileKey(_characterName, byCharacter: true)
|
||||
: ProfileKey(_selected, byCharacter: false);
|
||||
// This character's own "By char" JSON document, if not already a
|
||||
// real .utl file.
|
||||
string byCharacterFileName = CurrentFileName();
|
||||
if (VtankStorage.ReadText(byCharacterFileName) is null)
|
||||
{
|
||||
LootProfileDocument? byCharacter = ReadLegacyJson(
|
||||
ProfileKey(_characterName, byCharacter: true));
|
||||
if (byCharacter is not null)
|
||||
{
|
||||
WriteUtl(byCharacterFileName, byCharacter.ToVtankProfile());
|
||||
_host.Storage.Delete(ProfileKey(_characterName, byCharacter: true));
|
||||
}
|
||||
}
|
||||
|
||||
LegacyRosterDocument? roster = ReadLegacyRoster();
|
||||
if (roster?.Names is not { Count: > 0 } names)
|
||||
return;
|
||||
|
||||
var remaining = new List<string>();
|
||||
int migrated = 0;
|
||||
foreach (string rawName in names)
|
||||
{
|
||||
string name = (rawName ?? string.Empty).Trim();
|
||||
if (name.Length == 0 || name.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase))
|
||||
continue; // stale/invalid row; drop it rather than loop on it forever.
|
||||
|
||||
string legacyKey = ProfileKey(name, byCharacter: false);
|
||||
LootProfileDocument? legacy = ReadLegacyJson(legacyKey);
|
||||
if (legacy is null)
|
||||
continue; // already converted (or never existed); drop the row.
|
||||
|
||||
string fileName = ToFileName(name);
|
||||
if (VtankStorage.ReadText(fileName) is null)
|
||||
WriteUtl(fileName, legacy.ToVtankProfile());
|
||||
_host.Storage.Delete(legacyKey);
|
||||
migrated++;
|
||||
}
|
||||
|
||||
if (migrated == 0)
|
||||
return;
|
||||
if (remaining.Count > 0)
|
||||
{
|
||||
_host.Storage.WriteText(
|
||||
LegacyRosterKey,
|
||||
JsonSerializer.Serialize(new LegacyRosterDocument { Names = remaining }, Options));
|
||||
}
|
||||
else
|
||||
{
|
||||
_host.Storage.Delete(LegacyRosterKey);
|
||||
}
|
||||
_host.Log.Warn($"Migrated {migrated} legacy MossTank named loot profile(s) from the old roster.");
|
||||
}
|
||||
|
||||
private LegacyRosterDocument? ReadLegacyRoster()
|
||||
{
|
||||
string? json = null;
|
||||
try
|
||||
{
|
||||
json = _host.Storage.ReadText(LegacyRosterKey);
|
||||
return string.IsNullOrWhiteSpace(json)
|
||||
? null
|
||||
: JsonSerializer.Deserialize<LegacyRosterDocument>(json, Options);
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
RecoveryNotice = MossTankProfileRecovery.Preserve(
|
||||
_host, "loot", LegacyRosterKey, json, error);
|
||||
_host.Log.Warn(RecoveryNotice);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private LootProfileDocument? ReadLegacyJson(string key)
|
||||
{
|
||||
if (!_host.Storage.IsAvailable)
|
||||
return null;
|
||||
string? json = null;
|
||||
try
|
||||
{
|
||||
json = _host.Storage.ReadText(key);
|
||||
return string.IsNullOrWhiteSpace(json)
|
||||
? null
|
||||
: JsonSerializer.Deserialize<LootProfileDocument>(json, Options);
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
RecoveryNotice = MossTankProfileRecovery.Preserve(_host, "loot", key, json, error);
|
||||
_host.Log.Warn(RecoveryNotice);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// File naming, storage plumbing.
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
private string CurrentFileName() => _selected.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase)
|
||||
? VtankProfileDirectory.AutoCharacterFileName(_characterName, Server, "utl")
|
||||
: _selected;
|
||||
|
||||
private static string ToFileName(string bareName) =>
|
||||
bareName.EndsWith(".utl", StringComparison.OrdinalIgnoreCase)
|
||||
? bareName
|
||||
: bareName + ".utl";
|
||||
|
||||
private void WriteBinding()
|
||||
{
|
||||
if (!CanBindFiles || !VtankStorage.IsAvailable)
|
||||
return;
|
||||
VtankProfileDirectory.VtankCharacterBinding existing =
|
||||
VtankProfileDirectory.TryReadCharacterBinding(VtankStorage, _characterName, Server)
|
||||
?? new VtankProfileDirectory.VtankCharacterBinding(
|
||||
string.Empty, CurrentFileName(), string.Empty, null);
|
||||
VtankProfileDirectory.WriteCharacterBinding(
|
||||
VtankStorage,
|
||||
_characterName,
|
||||
Server,
|
||||
existing with { LootFileName = CurrentFileName() });
|
||||
}
|
||||
|
||||
private void WriteUtl(string fileName, VtankLootProfile profile)
|
||||
{
|
||||
if (!VtankStorage.IsAvailable)
|
||||
return;
|
||||
AttachMossTankExpressions(profile);
|
||||
try
|
||||
{
|
||||
VtankStorage.WriteText(fileName, VtankLootProfileSerializer.Write(profile));
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
_host.Log.Warn($"MossTank loot profile could not be saved: {error.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// MossTank rule-expression preservation (round 3 item 9): VTClassic's
|
||||
// real .utl requirement grammar cannot express an arbitrary MossTank
|
||||
// Expression string at all (VtankLootProfileSerializer.ExportRequirements
|
||||
// replaces an empty VtankRequirements list with a "safely disabled"
|
||||
// placeholder requirement for VTClassic compatibility) — fine when
|
||||
// .utl was only ever a courtesy export mirror alongside the
|
||||
// authoritative JSON store, but silently DESTRUCTIVE now that .utl is
|
||||
// the SOLE store: every MossTank-authored rule's Expression would be
|
||||
// permanently lost on its very first save/reload cycle. Preserved
|
||||
// instead in a MossTank-owned length-delimited block
|
||||
// (VtankLootProfileSerializer's own UnknownBlocks mechanism already
|
||||
// round-trips any block type it doesn't recognize as "SalvageCombine"
|
||||
// verbatim — exactly the "a foreign reader keeps what it doesn't
|
||||
// understand" contract this needs) so a real VTClassic reading the
|
||||
// same file simply ignores it as an unknown block, the same as any
|
||||
// other extension block.
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
private const string MossTankRulesBlockType = "MossTankRuleExpressions";
|
||||
|
||||
private static void AttachMossTankExpressions(VtankLootProfile profile)
|
||||
{
|
||||
profile.UnknownBlocks.RemoveAll(static block =>
|
||||
string.Equals(block.Type, MossTankRulesBlockType, StringComparison.Ordinal));
|
||||
|
||||
var payload = new StringBuilder();
|
||||
payload.Append(profile.Rules.Count.ToString(CultureInfo.InvariantCulture)).Append("\r\n");
|
||||
foreach (LootRule rule in profile.Rules)
|
||||
{
|
||||
// A rule with real VtankRequirements (imported from a genuine
|
||||
// VTClassic file, never touched by MossTank's own editor) has
|
||||
// nothing of ours to preserve — record an empty slot so load
|
||||
// leaves its VtankRequirements-derived state alone.
|
||||
string expression = rule.VtankRequirements.Count > 0 ? string.Empty : rule.Expression;
|
||||
payload.Append(expression.Length.ToString(CultureInfo.InvariantCulture)).Append("\r\n");
|
||||
payload.Append(expression);
|
||||
}
|
||||
profile.UnknownBlocks.Add(new VtankLootExtraBlock
|
||||
{
|
||||
Type = MossTankRulesBlockType,
|
||||
Payload = payload.ToString(),
|
||||
});
|
||||
}
|
||||
|
||||
private static void ApplyMossTankExpressions(VtankLootProfile profile)
|
||||
{
|
||||
VtankLootExtraBlock? block = profile.UnknownBlocks.FirstOrDefault(candidate =>
|
||||
string.Equals(candidate.Type, MossTankRulesBlockType, StringComparison.Ordinal));
|
||||
if (block is null)
|
||||
return;
|
||||
profile.UnknownBlocks.Remove(block);
|
||||
|
||||
string payload = block.Payload ?? string.Empty;
|
||||
int position = 0;
|
||||
if (!TryReadPayloadLine(payload, ref position, out string countText)
|
||||
|| !int.TryParse(countText, NumberStyles.Integer, CultureInfo.InvariantCulture, out int count))
|
||||
{
|
||||
return; // Corrupt/foreign block reusing our type name; ignore rather than throw.
|
||||
}
|
||||
int limit = Math.Min(count, profile.Rules.Count);
|
||||
for (int index = 0; index < limit; index++)
|
||||
{
|
||||
if (!TryReadPayloadLine(payload, ref position, out string lengthText)
|
||||
|| !int.TryParse(lengthText, NumberStyles.Integer, CultureInfo.InvariantCulture, out int length)
|
||||
|| length < 0
|
||||
|| length > payload.Length - position)
|
||||
{
|
||||
return; // Truncated/corrupt; stop applying rather than throw.
|
||||
}
|
||||
string expression = payload.Substring(position, length);
|
||||
position += length;
|
||||
if (expression.Length == 0)
|
||||
continue;
|
||||
profile.Rules[index].Expression = expression;
|
||||
profile.Rules[index].VtankRequirements.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryReadPayloadLine(string text, ref int position, out string line)
|
||||
{
|
||||
if (position > text.Length)
|
||||
{
|
||||
line = string.Empty;
|
||||
return false;
|
||||
}
|
||||
int start = position;
|
||||
while (position < text.Length && text[position] is not ('\r' or '\n'))
|
||||
position++;
|
||||
line = text[start..position];
|
||||
if (position < text.Length && text[position] == '\r')
|
||||
position++;
|
||||
if (position < text.Length && text[position] == '\n')
|
||||
position++;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string ProfileKey(string value, bool byCharacter)
|
||||
{
|
||||
|
|
@ -290,90 +581,13 @@ internal sealed class MossTankLootProfileStore
|
|||
return $"profiles/loot/{hash}.json";
|
||||
}
|
||||
|
||||
private string SelectionKey() => string.IsNullOrWhiteSpace(_characterName)
|
||||
? "_default"
|
||||
: _characterName;
|
||||
// ------------------------------------------------------------------
|
||||
// Migration-only shapes: the OLD JSON roster and per-profile document,
|
||||
// kept solely so SweepLegacyRosterIfNeeded can recover them once.
|
||||
// Nothing else in this file writes either shape again.
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
private T? Read<T>(string key) where T : class
|
||||
{
|
||||
if (!_host.Storage.IsAvailable)
|
||||
return null;
|
||||
string? json = null;
|
||||
try
|
||||
{
|
||||
json = _host.Storage.ReadText(key);
|
||||
return string.IsNullOrWhiteSpace(json)
|
||||
? null
|
||||
: JsonSerializer.Deserialize<T>(json, Options);
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
RecoveryNotice = MossTankProfileRecovery.Preserve(
|
||||
_host,
|
||||
"loot",
|
||||
key,
|
||||
json,
|
||||
error);
|
||||
_host.Log.Warn(RecoveryNotice);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private void Write<T>(string key, T document)
|
||||
{
|
||||
if (!_host.Storage.IsAvailable)
|
||||
return;
|
||||
try
|
||||
{
|
||||
_host.Storage.WriteText(key, JsonSerializer.Serialize(document, Options));
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
_host.Log.Warn($"MossTank loot profile could not be saved: {error.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void SaveIndex() => Write(IndexKey, _index);
|
||||
|
||||
private void WriteLegacyExport(string name, LootProfileDocument document)
|
||||
{
|
||||
if (!_host.Storage.IsAvailable)
|
||||
return;
|
||||
try
|
||||
{
|
||||
_host.Storage.WriteText(
|
||||
$"exports/{LegacyFileName(name)}.utl",
|
||||
VtankLootProfileSerializer.Write(document.ToVtankProfile()));
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
_host.Log.Warn(
|
||||
$"MossTank VTClassic loot export could not be saved: {error.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private string LegacyProfileName() => _selected.Equals(
|
||||
ByCharacter,
|
||||
StringComparison.OrdinalIgnoreCase)
|
||||
? string.IsNullOrWhiteSpace(_characterName)
|
||||
? ByCharacter
|
||||
: _characterName
|
||||
: _selected;
|
||||
|
||||
private static string LegacyFileName(string name)
|
||||
{
|
||||
char[] invalid = Path.GetInvalidFileNameChars();
|
||||
var result = new StringBuilder(name.Length);
|
||||
foreach (char value in name.Trim())
|
||||
{
|
||||
result.Append(value is '/' or '\\' || invalid.Contains(value)
|
||||
? '_'
|
||||
: value);
|
||||
}
|
||||
return result.Length == 0 ? "Loot" : result.ToString();
|
||||
}
|
||||
|
||||
private sealed class IndexDocument
|
||||
private sealed class LegacyRosterDocument
|
||||
{
|
||||
public int Version { get; set; } = 1;
|
||||
public List<string> Names { get; set; } = [];
|
||||
|
|
|
|||
|
|
@ -5,47 +5,76 @@ using AcDream.Plugin.Abstractions;
|
|||
|
||||
namespace AcDream.Plugins.MossTank;
|
||||
|
||||
/// <summary>Independent VTank-style By-char/named Meta profile lifetime.</summary>
|
||||
/// <summary>
|
||||
/// VTank-compatible Meta profile lifecycle. <c>.af</c> (metaf's human-
|
||||
/// readable grammar, see <see cref="MetafSerializer"/>) is the ONLY storage
|
||||
/// and authoring format — real VTank's own <c>ac()</c> meta-profile picker
|
||||
/// (<c>docs/research/vtank-kb/01-settings-and-profiles.md</c> section 3)
|
||||
/// seeds <c>[None]</c>/<c>[By char]</c> then every non-<c>--</c> file: named
|
||||
/// Meta profiles are plain shared files (unlike the Settings picker, there
|
||||
/// is no per-character <c>[Char] suffix</c> sub-profile carve-out here),
|
||||
/// while the per-character auto file uses VTank's <c>--Name_Server.af</c>
|
||||
/// naming and is therefore hidden from that listing by construction.
|
||||
/// </summary>
|
||||
internal sealed class MossTankMetaProfileStore
|
||||
{
|
||||
public const string ByCharacter = "By char";
|
||||
private const string IndexKey = "profiles/meta/index.json";
|
||||
private static readonly JsonSerializerOptions Options = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
};
|
||||
|
||||
// The pre-cutover roster (round 3 item 2): before the .af cutover this
|
||||
// store kept a flat Names list here (unlike the settings roster, named
|
||||
// Meta profiles were never owner-scoped — one shared, globally-hashed
|
||||
// key per name). The cutover stopped reading this key at all, orphaning
|
||||
// every named profile except whichever one a character had selected
|
||||
// (that one alone still converts, via MigrateLegacyIfNeeded). Read-only:
|
||||
// consulted ONLY by the one-time SweepLegacyRosterIfNeeded.
|
||||
private const string LegacyRosterKey = "profiles/meta/index.json";
|
||||
|
||||
private readonly IPluginHost _host;
|
||||
private IndexDocument _index;
|
||||
private string _character = string.Empty;
|
||||
private string _selected = ByCharacter;
|
||||
private string? _pendingLegacyBareName;
|
||||
private bool _rosterSwept;
|
||||
|
||||
public MossTankMetaProfileStore(IPluginHost host)
|
||||
{
|
||||
_host = host ?? throw new ArgumentNullException(nameof(host));
|
||||
_index = Read<IndexDocument>(IndexKey) ?? new IndexDocument();
|
||||
_index.Names ??= [];
|
||||
_index.SelectedByCharacter = new Dictionary<string, string>(
|
||||
_index.SelectedByCharacter ?? [],
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private List<string> Names => _index.Names ??= [];
|
||||
|
||||
private Dictionary<string, string> SelectedByCharacter =>
|
||||
_index.SelectedByCharacter ??= new Dictionary<string, string>(
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public string Selected => _selected;
|
||||
/// <summary>
|
||||
/// The bare name a user typed to select/create this profile (the file
|
||||
/// name minus its <c>.af</c> extension) — internal identity (<see cref="_selected"/>)
|
||||
/// always carries the real, on-disk file name; only display strips it.
|
||||
/// </summary>
|
||||
public string Selected => StripAf(_selected);
|
||||
public string? RecoveryNotice { get; private set; }
|
||||
public IReadOnlyList<string> AvailableNames => new[] { ByCharacter }
|
||||
.Concat(Names)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(static name => name.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase)
|
||||
? 0 : 1)
|
||||
.ThenBy(static name => name, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
public string? SaveNotice { get; private set; }
|
||||
|
||||
private string Server => _host.Automation.Character.WorldName;
|
||||
private IPluginStorage VtankStorage => _host.VtankProfiles;
|
||||
private bool CanBindFiles => _character.Length > 0 && Server.Length > 0;
|
||||
|
||||
private static string StripAf(string name) => name.Equals(
|
||||
ByCharacter, StringComparison.OrdinalIgnoreCase)
|
||||
? name
|
||||
: name.EndsWith(".af", StringComparison.OrdinalIgnoreCase)
|
||||
? name[..^3]
|
||||
: name;
|
||||
|
||||
public IReadOnlyList<string> AvailableNames
|
||||
{
|
||||
get
|
||||
{
|
||||
var names = new List<string> { ByCharacter };
|
||||
foreach (VtankProfileDirectory.ProfileEntry entry in
|
||||
VtankProfileDirectory.ListMetaProfiles(VtankStorage))
|
||||
{
|
||||
if (entry.FileName.Length == 0)
|
||||
continue; // VTank's own "[None]"/"[By char]" sentinels.
|
||||
names.Add(StripAf(entry.FileName));
|
||||
}
|
||||
return names;
|
||||
}
|
||||
}
|
||||
|
||||
public bool BindCharacter(string? characterName)
|
||||
{
|
||||
|
|
@ -55,33 +84,106 @@ internal sealed class MossTankMetaProfileStore
|
|||
if (normalized.Equals(_character, StringComparison.OrdinalIgnoreCase))
|
||||
return false;
|
||||
_character = normalized;
|
||||
_selected = SelectedByCharacter.TryGetValue(
|
||||
CharacterKey(),
|
||||
out string? selected)
|
||||
&& IsKnown(selected)
|
||||
? Canonical(selected)
|
||||
: ByCharacter;
|
||||
_pendingLegacyBareName = null;
|
||||
VtankProfileDirectory.VtankCharacterBinding? binding = CanBindFiles
|
||||
? VtankProfileDirectory.TryReadCharacterBinding(VtankStorage, _character, Server)
|
||||
: null;
|
||||
_selected = binding is { MetaFileName.Length: > 0 } bound
|
||||
? bound.MetaFileName
|
||||
: ByCharacter;
|
||||
return true;
|
||||
}
|
||||
|
||||
public MetaProfile LoadCurrent() =>
|
||||
Read<MetaProfile>(CurrentKey()) ?? new MetaProfile();
|
||||
|
||||
public void SaveCurrent(MetaProfile profile)
|
||||
public MetaProfile LoadCurrent()
|
||||
{
|
||||
Write(CurrentKey(), profile);
|
||||
WriteLegacyExport(LegacyProfileName(), profile);
|
||||
SweepLegacyRosterIfNeeded();
|
||||
MigrateLegacyIfNeeded();
|
||||
string fileName = CurrentFileName();
|
||||
string? text = VtankStorage.IsAvailable ? VtankStorage.ReadText(fileName) : null;
|
||||
if (text is null)
|
||||
return new MetaProfile();
|
||||
if (!MetafSerializer.TryLoadMeta(text, _host.Automation.Spells, out MetaProfile profile, out string error))
|
||||
{
|
||||
RecoveryNotice = MossTankProfileRecovery.Preserve(
|
||||
_host, "meta", fileName, text, new FormatException(error));
|
||||
_host.Log.Warn(RecoveryNotice);
|
||||
return new MetaProfile();
|
||||
}
|
||||
return profile;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Saves <paramref name="profile"/> as this selection's <c>.af</c> file.
|
||||
/// Real VTank/metaf has no way to represent <see cref="MetaRule.Enabled"/>
|
||||
/// <see langword="false"/> (a MossTank-only extension) — rather than
|
||||
/// silently dropping a disabled rule, the save is refused entirely (the
|
||||
/// file on disk keeps its last good content) and <see cref="SaveNotice"/>
|
||||
/// carries a user-visible reason. Returns <see langword="true"/> only
|
||||
/// when the file was actually written.
|
||||
/// </summary>
|
||||
public bool SaveCurrent(MetaProfile profile)
|
||||
{
|
||||
string fileName = CurrentFileName();
|
||||
string text;
|
||||
try
|
||||
{
|
||||
text = MetafSerializer.SaveMeta(profile);
|
||||
}
|
||||
catch (InvalidOperationException error)
|
||||
{
|
||||
SaveNotice = $"Meta profile '{fileName}' was NOT saved: {error.Message}";
|
||||
_host.Log.Warn(SaveNotice);
|
||||
return false;
|
||||
}
|
||||
if (!VtankStorage.IsAvailable)
|
||||
return false;
|
||||
try
|
||||
{
|
||||
VtankStorage.WriteText(fileName, text);
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
SaveNotice = $"Meta profile '{fileName}' could not be saved: {error.Message}";
|
||||
_host.Log.Warn(SaveNotice);
|
||||
return false;
|
||||
}
|
||||
SaveNotice = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool Select(string? name)
|
||||
{
|
||||
string normalized = Normalize(name);
|
||||
if (!IsKnown(normalized))
|
||||
if (normalized.Length == 0)
|
||||
return false;
|
||||
_selected = Canonical(normalized);
|
||||
SelectedByCharacter[CharacterKey()] = _selected;
|
||||
SaveIndex();
|
||||
return true;
|
||||
if (normalized.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_selected = ByCharacter;
|
||||
_pendingLegacyBareName = null;
|
||||
WriteBinding();
|
||||
return true;
|
||||
}
|
||||
|
||||
string plain = normalized.EndsWith(".af", StringComparison.OrdinalIgnoreCase)
|
||||
? normalized
|
||||
: normalized + ".af";
|
||||
if (VtankStorage.IsAvailable && VtankStorage.ReadText(plain) is not null)
|
||||
{
|
||||
_selected = plain;
|
||||
_pendingLegacyBareName = null;
|
||||
WriteBinding();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (_host.Storage.IsAvailable
|
||||
&& _host.Storage.ReadText(LegacyNamedKey(normalized)) is not null)
|
||||
{
|
||||
_selected = plain;
|
||||
_pendingLegacyBareName = normalized;
|
||||
WriteBinding();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool Create(
|
||||
|
|
@ -97,19 +199,18 @@ internal sealed class MossTankMetaProfileStore
|
|||
notice = "Enter a unique Meta profile name (1-64 characters).";
|
||||
return false;
|
||||
}
|
||||
MetaProfile document = copyCurrent
|
||||
? Clone(current)
|
||||
: new MetaProfile();
|
||||
Write(NamedKey(normalized), document);
|
||||
if (!Names.Contains(normalized, StringComparer.OrdinalIgnoreCase))
|
||||
Names.Add(normalized);
|
||||
_selected = normalized;
|
||||
SelectedByCharacter[CharacterKey()] = normalized;
|
||||
SaveIndex();
|
||||
WriteLegacyExport(normalized, document);
|
||||
string fileName = normalized.EndsWith(".af", StringComparison.OrdinalIgnoreCase)
|
||||
? normalized
|
||||
: normalized + ".af";
|
||||
MetaProfile document = copyCurrent ? Clone(current) : new MetaProfile();
|
||||
if (!SaveTo(fileName, document, out notice))
|
||||
return false;
|
||||
_selected = fileName;
|
||||
_pendingLegacyBareName = null;
|
||||
WriteBinding();
|
||||
notice = copyCurrent
|
||||
? $"Copied Meta profile to {normalized}."
|
||||
: $"Created Meta profile {normalized}.";
|
||||
? $"Copied Meta profile to {fileName}."
|
||||
: $"Created Meta profile {fileName}.";
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -139,20 +240,48 @@ internal sealed class MossTankMetaProfileStore
|
|||
notice = $"VTank Meta file '{normalized}.met' was not found in imports.";
|
||||
return false;
|
||||
}
|
||||
if (!VtankMetaProfileSerializer.TryLoad(source, out profile, out string error))
|
||||
if (!VtankMetaProfileSerializer.TryLoad(
|
||||
source, _host.Automation.Spells, out profile, out string error))
|
||||
{
|
||||
notice = $"Could not import {Path.GetFileName(key)}: {error}";
|
||||
return false;
|
||||
}
|
||||
if (!Names.Contains(normalized, StringComparer.OrdinalIgnoreCase))
|
||||
Names.Add(normalized);
|
||||
_selected = Names.First(existing => existing.Equals(
|
||||
normalized,
|
||||
StringComparison.OrdinalIgnoreCase));
|
||||
SelectedByCharacter[CharacterKey()] = _selected;
|
||||
SaveIndex();
|
||||
SaveCurrent(profile);
|
||||
notice = $"Imported VTank Meta profile {_selected}.";
|
||||
string fileName = normalized.EndsWith(".af", StringComparison.OrdinalIgnoreCase)
|
||||
? normalized
|
||||
: normalized + ".af";
|
||||
if (!SaveTo(fileName, profile, out string saveNotice))
|
||||
{
|
||||
notice = saveNotice;
|
||||
return false;
|
||||
}
|
||||
_selected = fileName;
|
||||
_pendingLegacyBareName = null;
|
||||
WriteBinding();
|
||||
notice = $"Imported VTank Meta profile {fileName}.";
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Round 3 item 10: deletes the currently selected named Meta profile's
|
||||
/// real <c>.af</c> file, then falls back to <see cref="ByCharacter"/>.
|
||||
/// Refuses for <see cref="ByCharacter"/> itself — there is no file to
|
||||
/// delete, only a reset (see <see cref="ClearCurrent"/>), matching
|
||||
/// <see cref="MossTankProfileStore.Delete"/>'s own contract.
|
||||
/// </summary>
|
||||
public bool Delete(out string notice)
|
||||
{
|
||||
if (_selected.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
notice = "'By char' is the built-in Meta profile and cannot be deleted.";
|
||||
return false;
|
||||
}
|
||||
string fileName = _selected;
|
||||
if (VtankStorage.IsAvailable)
|
||||
VtankStorage.Delete(fileName);
|
||||
_selected = ByCharacter;
|
||||
_pendingLegacyBareName = null;
|
||||
WriteBinding();
|
||||
notice = $"Deleted Meta profile {fileName}.";
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -163,124 +292,237 @@ internal sealed class MossTankMetaProfileStore
|
|||
return empty;
|
||||
}
|
||||
|
||||
private string CurrentKey() => _selected.Equals(
|
||||
ByCharacter,
|
||||
StringComparison.OrdinalIgnoreCase)
|
||||
? $"profiles/meta/by-character/{Hash(_character)}.json"
|
||||
: NamedKey(_selected);
|
||||
// ------------------------------------------------------------------
|
||||
// Legacy JSON -> .af migration.
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
private static string NamedKey(string name) =>
|
||||
$"profiles/meta/named/{Hash(name)}.json";
|
||||
|
||||
private string CharacterKey() =>
|
||||
string.IsNullOrWhiteSpace(_character) ? "anonymous" : _character;
|
||||
|
||||
private bool IsKnown(string name) => name.Equals(
|
||||
ByCharacter,
|
||||
StringComparison.OrdinalIgnoreCase)
|
||||
|| Names.Contains(name, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private string Canonical(string name) => name.Equals(
|
||||
ByCharacter,
|
||||
StringComparison.OrdinalIgnoreCase)
|
||||
? ByCharacter
|
||||
: Names.First(existing => existing.Equals(
|
||||
name,
|
||||
StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
private void SaveIndex() => Write(IndexKey, _index);
|
||||
|
||||
private void WriteLegacyExport(string name, MetaProfile profile)
|
||||
/// <summary>
|
||||
/// Round 3 item 2: a ONE-TIME sweep (guarded by <see cref="_rosterSwept"/>)
|
||||
/// over the pre-cutover roster at <see cref="LegacyRosterKey"/>. Unlike
|
||||
/// <see cref="MigrateLegacyIfNeeded"/> — which only ever converts the
|
||||
/// ONE currently-selected profile — this converts every OTHER named
|
||||
/// Meta profile the old roster still lists. Named Meta profiles were
|
||||
/// never owner-scoped (one shared, globally-hashed key per name), so —
|
||||
/// unlike the settings roster — every entry converts regardless of
|
||||
/// which character is currently bound.
|
||||
/// </summary>
|
||||
private void SweepLegacyRosterIfNeeded()
|
||||
{
|
||||
if (_rosterSwept)
|
||||
return;
|
||||
_rosterSwept = true;
|
||||
if (!_host.Storage.IsAvailable)
|
||||
return;
|
||||
try
|
||||
List<string>? names = ReadRosterNames();
|
||||
if (names is not { Count: > 0 })
|
||||
return;
|
||||
|
||||
var remaining = new List<string>();
|
||||
int migrated = 0;
|
||||
foreach (string rawName in names)
|
||||
{
|
||||
string name = (rawName ?? string.Empty).Trim();
|
||||
if (name.Length == 0 || name.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase))
|
||||
continue; // stale/invalid row; drop it rather than loop on it forever.
|
||||
|
||||
string legacyKey = LegacyNamedKey(name);
|
||||
MetaProfile? legacy = ReadLegacyJson(legacyKey);
|
||||
if (legacy is null)
|
||||
continue; // already converted (or never existed); drop the row.
|
||||
|
||||
string fileName = name.EndsWith(".af", StringComparison.OrdinalIgnoreCase)
|
||||
? name
|
||||
: name + ".af";
|
||||
if (VtankStorage.IsAvailable && VtankStorage.ReadText(fileName) is null)
|
||||
{
|
||||
if (!SaveTo(fileName, legacy, out string notice))
|
||||
{
|
||||
// Representational loss (a disabled rule) — same gate as
|
||||
// MigrateLegacyIfNeeded: keep the row so this can be
|
||||
// retried once the user resolves it.
|
||||
_host.Log.Warn($"MossTank could not sweep legacy Meta profile '{name}': {notice}");
|
||||
remaining.Add(name);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
_host.Storage.Delete(legacyKey);
|
||||
migrated++;
|
||||
}
|
||||
|
||||
if (migrated == 0)
|
||||
return;
|
||||
|
||||
if (remaining.Count > 0)
|
||||
{
|
||||
_host.Storage.WriteText(
|
||||
$"exports/{LegacyFileName(name)}.met",
|
||||
VtankMetaProfileSerializer.Save(profile));
|
||||
LegacyRosterKey,
|
||||
JsonSerializer.Serialize(new LegacyRosterDocument { Names = remaining }, JsonOptions));
|
||||
}
|
||||
else
|
||||
{
|
||||
_host.Storage.Delete(LegacyRosterKey);
|
||||
}
|
||||
_host.Log.Warn($"Migrated {migrated} legacy MossTank named Meta profile(s) from the old roster.");
|
||||
}
|
||||
|
||||
private List<string>? ReadRosterNames()
|
||||
{
|
||||
string? json = null;
|
||||
try
|
||||
{
|
||||
json = _host.Storage.ReadText(LegacyRosterKey);
|
||||
return string.IsNullOrWhiteSpace(json)
|
||||
? null
|
||||
: JsonSerializer.Deserialize<LegacyRosterDocument>(json, JsonOptions)?.Names;
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
_host.Log.Warn(
|
||||
$"MossTank VTank Meta export could not be saved: {error.Message}");
|
||||
RecoveryNotice = MossTankProfileRecovery.Preserve(
|
||||
_host, "meta", LegacyRosterKey, json, error);
|
||||
_host.Log.Warn(RecoveryNotice);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private string LegacyProfileName() => _selected.Equals(
|
||||
ByCharacter,
|
||||
StringComparison.OrdinalIgnoreCase)
|
||||
? string.IsNullOrWhiteSpace(_character) ? ByCharacter : _character
|
||||
: _selected;
|
||||
|
||||
private static string LegacyFileName(string name)
|
||||
private void MigrateLegacyIfNeeded()
|
||||
{
|
||||
char[] invalid = Path.GetInvalidFileNameChars();
|
||||
var result = new StringBuilder(name.Length);
|
||||
foreach (char value in name.Trim())
|
||||
string fileName = CurrentFileName();
|
||||
if (!VtankStorage.IsAvailable || VtankStorage.ReadText(fileName) is not null)
|
||||
{
|
||||
result.Append(value is '/' or '\\' || invalid.Contains(value)
|
||||
? '_'
|
||||
: value);
|
||||
_pendingLegacyBareName = null;
|
||||
return;
|
||||
}
|
||||
return result.Length == 0 ? "Meta" : result.ToString();
|
||||
string legacyKey = _pendingLegacyBareName is { Length: > 0 } bareName
|
||||
? LegacyNamedKey(bareName)
|
||||
: LegacyByCharacterKey();
|
||||
MetaProfile? legacy = ReadLegacyJson(legacyKey);
|
||||
if (legacy is null)
|
||||
{
|
||||
_pendingLegacyBareName = null;
|
||||
return;
|
||||
}
|
||||
if (!SaveTo(fileName, legacy, out string notice))
|
||||
{
|
||||
// Representational loss (a disabled rule) blocks the migration
|
||||
// outright rather than silently dropping it: the legacy JSON
|
||||
// stays put (still fully readable) until the user resolves it.
|
||||
_host.Log.Warn($"MossTank could not migrate legacy Meta profile: {notice}");
|
||||
_pendingLegacyBareName = null;
|
||||
return;
|
||||
}
|
||||
if (_host.Storage.IsAvailable)
|
||||
_host.Storage.Delete(legacyKey);
|
||||
_pendingLegacyBareName = null;
|
||||
_host.Log.Warn($"Migrated legacy MossTank Meta profile '{legacyKey}' to '{fileName}'.");
|
||||
}
|
||||
|
||||
private T? Read<T>(string key)
|
||||
private MetaProfile? ReadLegacyJson(string key)
|
||||
{
|
||||
if (!_host.Storage.IsAvailable)
|
||||
return default;
|
||||
return null;
|
||||
string? json = null;
|
||||
try
|
||||
{
|
||||
json = _host.Storage.ReadText(key);
|
||||
return string.IsNullOrWhiteSpace(json)
|
||||
? default
|
||||
: JsonSerializer.Deserialize<T>(json, Options);
|
||||
? null
|
||||
: JsonSerializer.Deserialize<MetaProfile>(json, JsonOptions);
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
RecoveryNotice = MossTankProfileRecovery.Preserve(
|
||||
_host,
|
||||
"meta",
|
||||
key,
|
||||
json,
|
||||
error);
|
||||
RecoveryNotice = MossTankProfileRecovery.Preserve(_host, "meta", key, json, error);
|
||||
_host.Log.Error(RecoveryNotice, error);
|
||||
return default;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private void Write<T>(string key, T value)
|
||||
private bool SaveTo(string fileName, MetaProfile profile, out string notice)
|
||||
{
|
||||
if (!_host.Storage.IsAvailable)
|
||||
return;
|
||||
string text;
|
||||
try
|
||||
{
|
||||
_host.Storage.WriteText(key, JsonSerializer.Serialize(value, Options));
|
||||
text = MetafSerializer.SaveMeta(profile);
|
||||
}
|
||||
catch (InvalidOperationException error)
|
||||
{
|
||||
notice = $"Meta profile '{fileName}' was NOT saved: {error.Message}";
|
||||
SaveNotice = notice;
|
||||
_host.Log.Warn(notice);
|
||||
return false;
|
||||
}
|
||||
if (!VtankStorage.IsAvailable)
|
||||
{
|
||||
notice = "VTank Meta storage is unavailable.";
|
||||
return false;
|
||||
}
|
||||
try
|
||||
{
|
||||
VtankStorage.WriteText(fileName, text);
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
_host.Log.Error($"Unable to save MossTank Meta profile '{key}'.", error);
|
||||
notice = $"Meta profile '{fileName}' could not be saved: {error.Message}";
|
||||
SaveNotice = notice;
|
||||
_host.Log.Warn(notice);
|
||||
return false;
|
||||
}
|
||||
SaveNotice = null;
|
||||
notice = string.Empty;
|
||||
return true;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// File naming, storage plumbing.
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
private string CurrentFileName() => _selected.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase)
|
||||
? VtankProfileDirectory.AutoCharacterFileName(_character, Server, "af")
|
||||
: _selected;
|
||||
|
||||
private void WriteBinding()
|
||||
{
|
||||
if (!CanBindFiles || !VtankStorage.IsAvailable)
|
||||
return;
|
||||
VtankProfileDirectory.VtankCharacterBinding existing =
|
||||
VtankProfileDirectory.TryReadCharacterBinding(VtankStorage, _character, Server)
|
||||
?? new VtankProfileDirectory.VtankCharacterBinding(
|
||||
string.Empty, string.Empty, string.Empty, CurrentFileName());
|
||||
VtankProfileDirectory.WriteCharacterBinding(
|
||||
VtankStorage,
|
||||
_character,
|
||||
Server,
|
||||
existing with { MetaFileName = CurrentFileName() });
|
||||
}
|
||||
|
||||
private static MetaProfile Clone(MetaProfile profile) =>
|
||||
JsonSerializer.Deserialize<MetaProfile>(
|
||||
JsonSerializer.Serialize(profile, Options),
|
||||
Options) ?? new MetaProfile();
|
||||
JsonSerializer.Serialize(profile, JsonOptions),
|
||||
JsonOptions) ?? new MetaProfile();
|
||||
|
||||
private static string Normalize(string? name) => name?.Trim() ?? string.Empty;
|
||||
|
||||
private string LegacyByCharacterKey() =>
|
||||
$"profiles/meta/by-character/{Hash(_character)}.json";
|
||||
|
||||
private static string LegacyNamedKey(string name) =>
|
||||
$"profiles/meta/named/{Hash(name)}.json";
|
||||
|
||||
private static string Hash(string value)
|
||||
{
|
||||
byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(value.ToLowerInvariant()));
|
||||
return Convert.ToHexString(hash.AsSpan(0, 12)).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private sealed class IndexDocument
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
public List<string>? Names { get; set; } = [];
|
||||
public Dictionary<string, string>? SelectedByCharacter { get; set; } = [];
|
||||
WriteIndented = true,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
};
|
||||
|
||||
// Read-only: the pre-cutover roster shape at LegacyRosterKey, kept
|
||||
// solely so SweepLegacyRosterIfNeeded can recover it once.
|
||||
private sealed class LegacyRosterDocument
|
||||
{
|
||||
public List<string> Names { get; set; } = [];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,6 +48,14 @@ internal sealed partial class MossTankPanel
|
|||
private readonly CombatSettings _combatSettings = new();
|
||||
private readonly InventorySettings _inventorySettings = new();
|
||||
private readonly NavigationSettings _navigationSettings = new();
|
||||
/// <summary>
|
||||
/// One stable bundle of every live settings object the VTank-catalog
|
||||
/// Settings profile (<c>.usd</c>) covers, built once so
|
||||
/// <see cref="MossTankProfileStore"/>'s Load/Save/Create/ClearCurrent/
|
||||
/// SetOptionInAll surface never has to repeat five constructor
|
||||
/// parameters at every call site.
|
||||
/// </summary>
|
||||
private readonly VtankSettingsProfileSerializer.AllSettings _allSettings;
|
||||
private readonly MossTankProfileStore _profiles;
|
||||
private readonly MossTankLootProfileStore _lootProfiles;
|
||||
private readonly MossTankRouteProfileStore _routeProfiles;
|
||||
|
|
@ -182,14 +190,17 @@ internal sealed partial class MossTankPanel
|
|||
{
|
||||
_host = host;
|
||||
_firstRunGuidancePending = NeedsFirstRunGuidance(host);
|
||||
_allSettings = new VtankSettingsProfileSerializer.AllSettings
|
||||
{
|
||||
Combat = _combatSettings,
|
||||
Buffs = _buffSettings,
|
||||
Vitals = _vitalSettings,
|
||||
Inventory = _inventorySettings,
|
||||
Navigation = _navigationSettings,
|
||||
};
|
||||
_profiles = new MossTankProfileStore(host);
|
||||
_profiles.BindCharacter(host.Automation.Character.Name);
|
||||
_profiles.LoadCurrent(
|
||||
_combatSettings,
|
||||
_buffSettings,
|
||||
_vitalSettings,
|
||||
_inventorySettings,
|
||||
_noBuffItemNames);
|
||||
_profiles.LoadCurrent(_allSettings, _noBuffItemNames);
|
||||
_lootProfiles = new MossTankLootProfileStore(host);
|
||||
_lootProfiles.BindCharacter(host.Automation.Character.Name);
|
||||
if (!_lootProfiles.LoadCurrent(
|
||||
|
|
@ -202,7 +213,7 @@ internal sealed partial class MossTankPanel
|
|||
}
|
||||
_routeProfiles = new MossTankRouteProfileStore(host);
|
||||
_routeProfiles.BindCharacter(host.Automation.Character.Name);
|
||||
if (!_routeProfiles.LoadCurrent(_navigationSettings))
|
||||
if (!_routeProfiles.LoadCurrent(_navigationSettings, host.Automation.Spells))
|
||||
_routeProfiles.SaveCurrent(_navigationSettings);
|
||||
_combat = new CombatController(host, _combatSettings, _vitalSettings);
|
||||
_buffCasterPreparer = new BuffCasterPreparer(
|
||||
|
|
@ -583,6 +594,7 @@ internal sealed partial class MossTankPanel
|
|||
public Action CopyLootProfile => () =>
|
||||
CreateLootProfileCore(copyCurrent: true);
|
||||
public Action ClearLootProfile => ClearLootProfileCore;
|
||||
public Action DeleteLootProfile => DeleteLootProfileCore;
|
||||
public Action CloseLootEditor => () => _lootEditorVisible = false;
|
||||
public Action<int> SelectLootRule => SelectLootRuleCore;
|
||||
public Action<string> SetLootExpressionDraft => value =>
|
||||
|
|
@ -745,6 +757,7 @@ internal sealed partial class MossTankPanel
|
|||
public Action CopyRouteProfile => () =>
|
||||
CreateRouteProfileCore(copyCurrent: true);
|
||||
public Action ClearRouteProfile => ClearRouteProfileCore;
|
||||
public Action DeleteRouteProfile => DeleteRouteProfileCore;
|
||||
public Action SetFollowTarget => CaptureFollowTarget;
|
||||
|
||||
// ── Meta profile / editor ────────────────────────────────────────────
|
||||
|
|
@ -815,6 +828,7 @@ internal sealed partial class MossTankPanel
|
|||
public Action CreateMetaProfile => () => CreateMetaProfileCore(copyCurrent: false);
|
||||
public Action CopyMetaProfile => () => CreateMetaProfileCore(copyCurrent: true);
|
||||
public Action ClearMetaProfile => ClearMetaProfileCore;
|
||||
public Action DeleteMetaProfile => DeleteMetaProfileCore;
|
||||
|
||||
// ── Profiles tab ─────────────────────────────────────────────────────
|
||||
public IReadOnlyList<string> MacroProfileNames => _profiles.AvailableNames;
|
||||
|
|
@ -839,6 +853,7 @@ internal sealed partial class MossTankPanel
|
|||
public Action CreateProfile => () => CreateProfileCore(copyCurrent: false);
|
||||
public Action CopyProfile => () => CreateProfileCore(copyCurrent: true);
|
||||
public Action ClearProfile => ClearProfileCore;
|
||||
public Action DeleteProfile => DeleteProfileCore;
|
||||
public Action ToggleMineOnly => () =>
|
||||
{
|
||||
string before = _profiles.Selected;
|
||||
|
|
@ -1413,6 +1428,17 @@ internal sealed partial class MossTankPanel
|
|||
_lootEditorNotice = $"Cleared {_lootProfiles.Selected}.";
|
||||
}
|
||||
|
||||
private void DeleteLootProfileCore()
|
||||
{
|
||||
if (!_lootProfiles.Delete(out string notice))
|
||||
{
|
||||
_lootEditorNotice = notice;
|
||||
return;
|
||||
}
|
||||
LoadLootProfile();
|
||||
_lootEditorNotice = notice;
|
||||
}
|
||||
|
||||
private void LoadLootProfile()
|
||||
{
|
||||
if (!_lootProfiles.LoadCurrent(
|
||||
|
|
@ -1587,7 +1613,15 @@ internal sealed partial class MossTankPanel
|
|||
AddRouteWaypoint(new RouteWaypoint
|
||||
{
|
||||
Type = type,
|
||||
Position = target.Position,
|
||||
// Retail's own header x/y/z for Portal2/UseNPC is dead weight —
|
||||
// e9/fa extend `at`, whose position accessor is the player's
|
||||
// OWN live position, never read back into anything meaningful
|
||||
// (docs/research/vtank-kb/06-navigation-and-nav.md section 1.2).
|
||||
// ReferencePosition is the embedded d-record: the real target
|
||||
// coordinate matched against live world objects by name+class+
|
||||
// proximity (Navigation.cs's TickUse).
|
||||
Position = _host.Automation.Navigation.Snapshot.Position,
|
||||
ReferencePosition = target.Position,
|
||||
ObjectId = target.ObjectId,
|
||||
ObjectName = target.Name,
|
||||
});
|
||||
|
|
@ -1764,9 +1798,20 @@ internal sealed partial class MossTankPanel
|
|||
_routeNotice = $"Cleared {_routeProfiles.Selected}.";
|
||||
}
|
||||
|
||||
private void DeleteRouteProfileCore()
|
||||
{
|
||||
if (!_routeProfiles.Delete(out string notice))
|
||||
{
|
||||
_routeNotice = notice;
|
||||
return;
|
||||
}
|
||||
LoadRouteProfile();
|
||||
_routeNotice = notice;
|
||||
}
|
||||
|
||||
private void LoadRouteProfile()
|
||||
{
|
||||
if (!_routeProfiles.LoadCurrent(_navigationSettings))
|
||||
if (!_routeProfiles.LoadCurrent(_navigationSettings, _host.Automation.Spells))
|
||||
_routeProfiles.SaveCurrent(_navigationSettings);
|
||||
if (_initialized)
|
||||
ApplyPersistedOptionOverrides();
|
||||
|
|
@ -2225,9 +2270,9 @@ internal sealed partial class MossTankPanel
|
|||
}
|
||||
_metaProfile.Rules.Add(rule);
|
||||
_selectedMetaRule = _metaProfile.Rules.Count - 1;
|
||||
SaveMetaProfile();
|
||||
RefreshMetaEditor();
|
||||
_metaNotice = $"Added rule in {rule.State}.";
|
||||
if (SaveMetaProfile())
|
||||
_metaNotice = $"Added rule in {rule.State}.";
|
||||
}
|
||||
|
||||
private void ApplyMetaRuleCore()
|
||||
|
|
@ -2244,9 +2289,9 @@ internal sealed partial class MossTankPanel
|
|||
}
|
||||
replacement.Id = current.Id;
|
||||
_metaProfile.Rules[_selectedMetaRule] = replacement;
|
||||
SaveMetaProfile();
|
||||
RefreshMetaEditor();
|
||||
_metaNotice = $"Updated rule in {replacement.State}.";
|
||||
if (SaveMetaProfile())
|
||||
_metaNotice = $"Updated rule in {replacement.State}.";
|
||||
}
|
||||
|
||||
private bool TryBuildMetaRule(out MetaRule rule, out string error)
|
||||
|
|
@ -2301,9 +2346,9 @@ internal sealed partial class MossTankPanel
|
|||
_selectedMetaRule = Math.Min(
|
||||
_selectedMetaRule,
|
||||
Math.Max(0, _metaProfile.Rules.Count - 1));
|
||||
SaveMetaProfile();
|
||||
RefreshMetaEditor();
|
||||
_metaNotice = $"Removed rule from {selected.State}.";
|
||||
if (SaveMetaProfile())
|
||||
_metaNotice = $"Removed rule from {selected.State}.";
|
||||
}
|
||||
|
||||
private void MoveMetaRule(int direction)
|
||||
|
|
@ -2318,9 +2363,9 @@ internal sealed partial class MossTankPanel
|
|||
_metaProfile.Rules.RemoveAt(_selectedMetaRule);
|
||||
_metaProfile.Rules.Insert(destination, rule);
|
||||
_selectedMetaRule = destination;
|
||||
SaveMetaProfile();
|
||||
RefreshMetaEditor();
|
||||
_metaNotice = $"Moved {rule.State} rule.";
|
||||
if (SaveMetaProfile())
|
||||
_metaNotice = $"Moved {rule.State} rule.";
|
||||
}
|
||||
|
||||
private void SelectMetaProfileCore(string name)
|
||||
|
|
@ -2360,6 +2405,17 @@ internal sealed partial class MossTankPanel
|
|||
_metaNotice = $"Cleared Meta profile {_metaProfiles.Selected}.";
|
||||
}
|
||||
|
||||
private void DeleteMetaProfileCore()
|
||||
{
|
||||
if (!_metaProfiles.Delete(out string notice))
|
||||
{
|
||||
_metaNotice = notice;
|
||||
return;
|
||||
}
|
||||
LoadMetaProfile();
|
||||
_metaNotice = notice;
|
||||
}
|
||||
|
||||
private void LoadMetaProfile()
|
||||
{
|
||||
_metaProfile = _metaProfiles.LoadCurrent();
|
||||
|
|
@ -2370,7 +2426,22 @@ internal sealed partial class MossTankPanel
|
|||
RefreshMetaEditor();
|
||||
}
|
||||
|
||||
private void SaveMetaProfile() => _metaProfiles.SaveCurrent(_metaProfile);
|
||||
/// <summary>
|
||||
/// Saves the current Meta profile as <c>.af</c>. Returns
|
||||
/// <see langword="false"/> when the save was refused (a disabled rule
|
||||
/// has no metaf-compatible representation) — callers must not overwrite
|
||||
/// <see cref="_metaNotice"/> with a generic success message in that
|
||||
/// case, since <see cref="MossTankMetaProfileStore.SaveNotice"/> already
|
||||
/// carries the user-visible reason.
|
||||
/// </summary>
|
||||
private bool SaveMetaProfile()
|
||||
{
|
||||
if (_metaProfiles.SaveCurrent(_metaProfile))
|
||||
return true;
|
||||
if (_metaProfiles.SaveNotice is { } notice)
|
||||
_metaNotice = notice;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string DescribeMetaCondition(MetaCondition condition) =>
|
||||
condition.Kind switch
|
||||
|
|
@ -2408,19 +2479,16 @@ internal sealed partial class MossTankPanel
|
|||
return nearest;
|
||||
}
|
||||
|
||||
private void LoadEmbeddedNavigationRoute(string source)
|
||||
private void LoadEmbeddedNavigationRoute(NavigationSettings? route)
|
||||
{
|
||||
_navigation.Reset();
|
||||
if (!VtankNavRouteSerializer.TryLoad(
|
||||
source,
|
||||
_navigationSettings,
|
||||
_host.Automation.Spells,
|
||||
out string error))
|
||||
if (route is null)
|
||||
{
|
||||
_routeNotice = $"Embedded route rejected: {error}";
|
||||
_host.Log.Warn($"MossTank Meta embedded route rejected: {error}");
|
||||
_routeNotice = "Embedded route rejected: unresolved Nav tag.";
|
||||
_host.Log.Warn("MossTank Meta embedded route rejected: unresolved Nav tag.");
|
||||
return;
|
||||
}
|
||||
VtankNavRouteSerializer.Apply(route, _navigationSettings);
|
||||
_routeProfiles.SaveCurrent(_navigationSettings);
|
||||
_selectedRouteWaypoint = 0;
|
||||
RefreshRouteEditor();
|
||||
|
|
@ -2528,7 +2596,7 @@ internal sealed partial class MossTankPanel
|
|||
_combatSettings.DebuffPrecastSeconds),
|
||||
"switchwandstodebuff" => ExpressionValue.Boolean(
|
||||
_combatSettings.SwitchWandsToDebuff),
|
||||
"usearcs" => ExpressionValue.Boolean(_combatSettings.UseArcs),
|
||||
"usearcs" => ExpressionValue.Number((int)_combatSettings.UseArcs),
|
||||
"deleteghostmonsters" => ExpressionValue.Boolean(
|
||||
_combatSettings.DeleteGhostMonsters),
|
||||
"ghostmonsterspellattemptcount" => ExpressionValue.Number(
|
||||
|
|
@ -2998,7 +3066,8 @@ internal sealed partial class MossTankPanel
|
|||
_combatSettings.SwitchWandsToDebuff = value.IsTruthy;
|
||||
break;
|
||||
case "usearcs":
|
||||
_combatSettings.UseArcs = value.IsTruthy;
|
||||
_combatSettings.UseArcs = (UseArcsMode)Math.Clamp(
|
||||
value.AsInt32("UseArcs"), 1, 3);
|
||||
break;
|
||||
case "deleteghostmonsters":
|
||||
_combatSettings.DeleteGhostMonsters = value.IsTruthy;
|
||||
|
|
@ -3414,10 +3483,7 @@ internal sealed partial class MossTankPanel
|
|||
if (!_profiles.Create(
|
||||
_profileNameDraft,
|
||||
copyCurrent,
|
||||
_combatSettings,
|
||||
_buffSettings,
|
||||
_vitalSettings,
|
||||
_inventorySettings,
|
||||
_allSettings,
|
||||
_noBuffItemNames,
|
||||
out string notice))
|
||||
{
|
||||
|
|
@ -3431,24 +3497,25 @@ internal sealed partial class MossTankPanel
|
|||
|
||||
private void ClearProfileCore()
|
||||
{
|
||||
_profiles.ClearCurrent(
|
||||
_combatSettings,
|
||||
_buffSettings,
|
||||
_vitalSettings,
|
||||
_inventorySettings,
|
||||
_noBuffItemNames);
|
||||
_profiles.ClearCurrent(_allSettings, _noBuffItemNames);
|
||||
_profileLifecycleNotice = $"Cleared {_profiles.Selected} to VTank defaults.";
|
||||
ResetProfileConsumers();
|
||||
}
|
||||
|
||||
private void DeleteProfileCore()
|
||||
{
|
||||
if (!_profiles.Delete(out string notice))
|
||||
{
|
||||
_profileLifecycleNotice = notice;
|
||||
return;
|
||||
}
|
||||
LoadSelectedProfile();
|
||||
_profileLifecycleNotice = notice;
|
||||
}
|
||||
|
||||
private void LoadSelectedProfile()
|
||||
{
|
||||
_profiles.LoadCurrent(
|
||||
_combatSettings,
|
||||
_buffSettings,
|
||||
_vitalSettings,
|
||||
_inventorySettings,
|
||||
_noBuffItemNames);
|
||||
_profiles.LoadCurrent(_allSettings, _noBuffItemNames);
|
||||
LoadLootProfile();
|
||||
LoadRouteProfile();
|
||||
ApplyPersistedOptionOverrides();
|
||||
|
|
@ -3532,12 +3599,7 @@ internal sealed partial class MossTankPanel
|
|||
|
||||
private void SaveProfile()
|
||||
{
|
||||
_profiles.SaveCurrent(
|
||||
_combatSettings,
|
||||
_buffSettings,
|
||||
_vitalSettings,
|
||||
_inventorySettings,
|
||||
_noBuffItemNames);
|
||||
_profiles.SaveCurrent(_allSettings, _noBuffItemNames);
|
||||
_lootProfiles.SaveCurrent(
|
||||
_inventorySettings.Loot.Rules,
|
||||
_inventorySettings.Loot);
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -6,45 +6,86 @@ using AcDream.Plugin.Abstractions;
|
|||
namespace AcDream.Plugins.MossTank;
|
||||
|
||||
/// <summary>
|
||||
/// Independent VTank navigation-profile lifecycle. The selected route is
|
||||
/// remembered per character; "By char" is a private route document and named
|
||||
/// profiles are reusable copies.
|
||||
/// VTank-compatible navigation-route lifecycle. <c>.af</c> is the ONLY
|
||||
/// storage/authoring format — but only for the fields metaf's own
|
||||
/// <c>NAV:</c> grammar actually represents (<see cref="NavigationSettings.Mode"/>,
|
||||
/// <see cref="NavigationSettings.Waypoints"/>,
|
||||
/// <see cref="NavigationSettings.FollowTargetObjectId"/>/
|
||||
/// <see cref="NavigationSettings.FollowTargetName"/> — see
|
||||
/// <see cref="MetafSerializer.SaveNav"/>/<see cref="MetafSerializer.TryLoadNav"/>).
|
||||
/// Every other <see cref="NavigationSettings"/> field
|
||||
/// (Enabled/Priority/MinimumDistanceMeters/FollowAroundCorners/OpenDoors/
|
||||
/// Door*) is a real VTank Settings-table row with its own catalog name
|
||||
/// (<c>EnableNav</c>, <c>NavPriorityBoost</c>, <c>NavCloseStopRange</c>,
|
||||
/// …) already owned end-to-end by <see cref="MossTankProfileStore"/>'s
|
||||
/// <c>.usd</c> profile — this store never touches them, matching real
|
||||
/// VTank's own split between the Settings-scoped nav prefs and the
|
||||
/// per-route file.
|
||||
///
|
||||
/// Named route files carry a <c>nav_</c> prefix (metaf's own observed
|
||||
/// convention for a stand-alone nav-only <c>.af</c>, see the committed
|
||||
/// <c>nav_*.af</c> fixtures) so a route and a Meta profile sharing the same
|
||||
/// user-typed name never collide in the shared <see cref="IPluginHost.VtankProfiles"/>
|
||||
/// directory.
|
||||
/// </summary>
|
||||
internal sealed class MossTankRouteProfileStore
|
||||
{
|
||||
public const string ByCharacter = "By char";
|
||||
private const string IndexKey = "profiles/route/index.json";
|
||||
private static readonly JsonSerializerOptions Options = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
};
|
||||
private const string NavPrefix = "nav_";
|
||||
// The pre-cutover roster (round 3 item 2): before the .af cutover this
|
||||
// store kept a flat Names list here (named routes were never
|
||||
// owner-scoped — one shared, globally-hashed key per name). The
|
||||
// cutover stopped reading this key at all, orphaning every named route
|
||||
// except whichever one a character had selected (that one alone still
|
||||
// converts, via MigrateLegacyIfNeeded). Read-only: consulted ONLY by
|
||||
// the one-time SweepLegacyRosterIfNeeded.
|
||||
private const string LegacyRosterKey = "profiles/route/index.json";
|
||||
|
||||
private readonly IPluginHost _host;
|
||||
private IndexDocument _index;
|
||||
private string _characterName = string.Empty;
|
||||
private string _selected = ByCharacter;
|
||||
private string? _pendingLegacyBareName;
|
||||
private bool _rosterSwept;
|
||||
|
||||
public MossTankRouteProfileStore(IPluginHost host)
|
||||
{
|
||||
_host = host ?? throw new ArgumentNullException(nameof(host));
|
||||
_index = Read<IndexDocument>(IndexKey) ?? new IndexDocument();
|
||||
_index.Names ??= [];
|
||||
_index.SelectedByCharacter = new Dictionary<string, string>(
|
||||
_index.SelectedByCharacter ?? new Dictionary<string, string>(),
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public string Selected => _selected;
|
||||
public string Selected => Strip(_selected);
|
||||
public string? RecoveryNotice { get; private set; }
|
||||
public IReadOnlyList<string> AvailableNames => new[] { ByCharacter }
|
||||
.Concat(_index.Names)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(name => name.Equals(
|
||||
ByCharacter,
|
||||
StringComparison.OrdinalIgnoreCase) ? 0 : 1)
|
||||
.ThenBy(static name => name, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
private string Server => _host.Automation.Character.WorldName;
|
||||
private IPluginStorage VtankStorage => _host.VtankProfiles;
|
||||
private bool CanBindFiles => _characterName.Length > 0 && Server.Length > 0;
|
||||
|
||||
private static string Strip(string fileName)
|
||||
{
|
||||
if (fileName.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase))
|
||||
return fileName;
|
||||
string value = fileName;
|
||||
if (value.StartsWith(NavPrefix, StringComparison.Ordinal))
|
||||
value = value[NavPrefix.Length..];
|
||||
if (value.EndsWith(".af", StringComparison.OrdinalIgnoreCase))
|
||||
value = value[..^3];
|
||||
return value;
|
||||
}
|
||||
|
||||
public IReadOnlyList<string> AvailableNames
|
||||
{
|
||||
get
|
||||
{
|
||||
var names = new List<string> { ByCharacter };
|
||||
foreach (VtankProfileDirectory.ProfileEntry entry in
|
||||
VtankProfileDirectory.ListNavigationProfiles(VtankStorage))
|
||||
{
|
||||
if (entry.FileName.Length == 0)
|
||||
continue; // VTank's own "[None]"/"[By char]" sentinels.
|
||||
names.Add(Strip(entry.FileName));
|
||||
}
|
||||
return names;
|
||||
}
|
||||
}
|
||||
|
||||
public bool BindCharacter(string? characterName)
|
||||
{
|
||||
|
|
@ -54,24 +95,46 @@ internal sealed class MossTankRouteProfileStore
|
|||
if (string.Equals(normalized, _characterName, StringComparison.OrdinalIgnoreCase))
|
||||
return false;
|
||||
_characterName = normalized;
|
||||
_selected = _index.SelectedByCharacter.TryGetValue(
|
||||
SelectionKey(),
|
||||
out string? selected)
|
||||
&& IsKnown(selected)
|
||||
? CanonicalName(selected)
|
||||
: ByCharacter;
|
||||
_pendingLegacyBareName = null;
|
||||
VtankProfileDirectory.VtankCharacterBinding? binding = CanBindFiles
|
||||
? VtankProfileDirectory.TryReadCharacterBinding(VtankStorage, _characterName, Server)
|
||||
: null;
|
||||
_selected = binding is { NavFileName.Length: > 0 } bound
|
||||
? bound.NavFileName
|
||||
: ByCharacter;
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool Select(string? name)
|
||||
{
|
||||
string normalized = name?.Trim() ?? string.Empty;
|
||||
if (!IsKnown(normalized))
|
||||
string normalized = Normalize(name);
|
||||
if (normalized.Length == 0)
|
||||
return false;
|
||||
_selected = CanonicalName(normalized);
|
||||
_index.SelectedByCharacter[SelectionKey()] = _selected;
|
||||
SaveIndex();
|
||||
return true;
|
||||
if (normalized.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_selected = ByCharacter;
|
||||
_pendingLegacyBareName = null;
|
||||
WriteBinding();
|
||||
return true;
|
||||
}
|
||||
|
||||
string candidate = ToFileName(normalized);
|
||||
if (VtankStorage.IsAvailable && VtankStorage.ReadText(candidate) is not null)
|
||||
{
|
||||
_selected = candidate;
|
||||
_pendingLegacyBareName = null;
|
||||
WriteBinding();
|
||||
return true;
|
||||
}
|
||||
if (_host.Storage.IsAvailable
|
||||
&& _host.Storage.ReadText(LegacyProfileKey(normalized, byCharacter: false)) is not null)
|
||||
{
|
||||
_selected = candidate;
|
||||
_pendingLegacyBareName = normalized;
|
||||
WriteBinding();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool Create(
|
||||
|
|
@ -80,7 +143,7 @@ internal sealed class MossTankRouteProfileStore
|
|||
NavigationSettings current,
|
||||
out string notice)
|
||||
{
|
||||
string normalized = name?.Trim() ?? string.Empty;
|
||||
string normalized = Normalize(name);
|
||||
if (normalized.Length is < 1 or > 64)
|
||||
{
|
||||
notice = "Enter a route profile name (1-64 characters).";
|
||||
|
|
@ -91,46 +154,40 @@ internal sealed class MossTankRouteProfileStore
|
|||
notice = "'By char' is the built-in route profile.";
|
||||
return false;
|
||||
}
|
||||
Write(
|
||||
ProfileKey(normalized, byCharacter: false),
|
||||
copyCurrent
|
||||
? RouteDocument.Capture(current)
|
||||
: new RouteDocument());
|
||||
if (!_index.Names.Contains(normalized, StringComparer.OrdinalIgnoreCase))
|
||||
_index.Names.Add(normalized);
|
||||
_selected = _index.Names.First(entry => entry.Equals(
|
||||
normalized,
|
||||
StringComparison.OrdinalIgnoreCase));
|
||||
_index.SelectedByCharacter[SelectionKey()] = _selected;
|
||||
SaveIndex();
|
||||
WriteLegacyExport(_selected, copyCurrent ? current : new NavigationSettings());
|
||||
string fileName = ToFileName(normalized);
|
||||
NavigationSettings source = copyCurrent ? current : new NavigationSettings();
|
||||
WriteAf(fileName, MetafSerializer.SaveNav(source));
|
||||
_selected = fileName;
|
||||
_pendingLegacyBareName = null;
|
||||
WriteBinding();
|
||||
notice = copyCurrent
|
||||
? $"Copied route to {_selected}."
|
||||
: $"Created route profile {_selected}.";
|
||||
? $"Copied route to {Strip(fileName)}."
|
||||
: $"Created route profile {Strip(fileName)}.";
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool LoadCurrent(NavigationSettings target)
|
||||
public bool LoadCurrent(NavigationSettings target, ISpellCatalog spells)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(target);
|
||||
RouteDocument? document = Read<RouteDocument>(CurrentKey());
|
||||
if (document is null)
|
||||
ArgumentNullException.ThrowIfNull(spells);
|
||||
SweepLegacyRosterIfNeeded();
|
||||
MigrateLegacyIfNeeded(target, spells);
|
||||
string fileName = CurrentFileName();
|
||||
string? text = VtankStorage.IsAvailable ? VtankStorage.ReadText(fileName) : null;
|
||||
if (text is null)
|
||||
return false;
|
||||
document.Apply(target);
|
||||
if (!MetafSerializer.TryLoadNav(text, target, spells, out string error))
|
||||
{
|
||||
RecoveryNotice = MossTankProfileRecovery.Preserve(
|
||||
_host, "route", fileName, text, new FormatException(error));
|
||||
_host.Log.Warn(RecoveryNotice);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public void SaveCurrent(NavigationSettings settings)
|
||||
{
|
||||
Write(CurrentKey(), RouteDocument.Capture(settings));
|
||||
WriteLegacyExport(
|
||||
_selected.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase)
|
||||
? string.IsNullOrWhiteSpace(_characterName)
|
||||
? ByCharacter
|
||||
: _characterName
|
||||
: _selected,
|
||||
settings);
|
||||
}
|
||||
public void SaveCurrent(NavigationSettings settings) =>
|
||||
WriteAf(CurrentFileName(), MetafSerializer.SaveNav(settings));
|
||||
|
||||
public bool TryImportLegacy(
|
||||
string? name,
|
||||
|
|
@ -138,7 +195,7 @@ internal sealed class MossTankRouteProfileStore
|
|||
ISpellCatalog spells,
|
||||
out string notice)
|
||||
{
|
||||
string normalized = name?.Trim() ?? string.Empty;
|
||||
string normalized = Normalize(name);
|
||||
if (!_host.Storage.IsAvailable || normalized.Length == 0)
|
||||
{
|
||||
notice = "Legacy navigation storage is unavailable.";
|
||||
|
|
@ -162,64 +219,163 @@ internal sealed class MossTankRouteProfileStore
|
|||
notice = $"Could not import {Path.GetFileName(key)}: {error}";
|
||||
return false;
|
||||
}
|
||||
if (!_index.Names.Contains(normalized, StringComparer.OrdinalIgnoreCase))
|
||||
_index.Names.Add(normalized);
|
||||
_selected = _index.Names.First(entry => entry.Equals(
|
||||
normalized,
|
||||
StringComparison.OrdinalIgnoreCase));
|
||||
_index.SelectedByCharacter[SelectionKey()] = _selected;
|
||||
SaveIndex();
|
||||
SaveCurrent(target);
|
||||
notice = $"Imported VTank navigation profile {_selected}.";
|
||||
string fileName = ToFileName(normalized);
|
||||
WriteAf(fileName, MetafSerializer.SaveNav(target));
|
||||
_selected = fileName;
|
||||
_pendingLegacyBareName = null;
|
||||
WriteBinding();
|
||||
notice = $"Imported VTank navigation profile {Strip(fileName)}.";
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Round 3 item 10: deletes the currently selected named route's real
|
||||
/// <c>.af</c> file, then falls back to <see cref="ByCharacter"/>.
|
||||
/// Refuses for <see cref="ByCharacter"/> itself — there is no file to
|
||||
/// delete, only a reset (see <see cref="ClearCurrent"/>), matching
|
||||
/// <see cref="MossTankProfileStore.Delete"/>'s own contract.
|
||||
/// </summary>
|
||||
public bool Delete(out string notice)
|
||||
{
|
||||
if (_selected.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
notice = "'By char' is the built-in route profile and cannot be deleted.";
|
||||
return false;
|
||||
}
|
||||
string fileName = _selected;
|
||||
if (VtankStorage.IsAvailable)
|
||||
VtankStorage.Delete(fileName);
|
||||
_selected = ByCharacter;
|
||||
_pendingLegacyBareName = null;
|
||||
WriteBinding();
|
||||
notice = $"Deleted route profile {Strip(fileName)}.";
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resets only the fields this store owns (Mode/Waypoints/FollowTarget)
|
||||
/// — Enabled/Priority/MinimumDistanceMeters/FollowAroundCorners/
|
||||
/// OpenDoors/Door* belong to the Settings profile and are left alone.
|
||||
/// </summary>
|
||||
public void ClearCurrent(NavigationSettings target)
|
||||
{
|
||||
target.Enabled = false;
|
||||
target.Priority = false;
|
||||
target.Mode = RouteMode.Circular;
|
||||
target.MinimumDistanceMeters = 2d;
|
||||
target.FollowTargetObjectId = 0u;
|
||||
target.FollowTargetName = string.Empty;
|
||||
target.FollowAroundCorners = true;
|
||||
target.OpenDoors = false;
|
||||
target.Waypoints.Clear();
|
||||
SaveCurrent(target);
|
||||
}
|
||||
|
||||
private bool IsKnown(string? name) => name is not null
|
||||
&& (name.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase)
|
||||
|| _index.Names.Contains(name, StringComparer.OrdinalIgnoreCase));
|
||||
// ------------------------------------------------------------------
|
||||
// Legacy JSON -> .af migration.
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
private string CanonicalName(string name) => name.Equals(
|
||||
ByCharacter,
|
||||
StringComparison.OrdinalIgnoreCase)
|
||||
? ByCharacter
|
||||
: _index.Names.First(entry => entry.Equals(
|
||||
name,
|
||||
StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
private string CurrentKey() => _selected.Equals(
|
||||
ByCharacter,
|
||||
StringComparison.OrdinalIgnoreCase)
|
||||
? ProfileKey(_characterName, byCharacter: true)
|
||||
: ProfileKey(_selected, byCharacter: false);
|
||||
|
||||
private static string ProfileKey(string value, bool byCharacter)
|
||||
/// <summary>
|
||||
/// Round 3 item 2: a ONE-TIME sweep (guarded by <see cref="_rosterSwept"/>)
|
||||
/// over the pre-cutover roster at <see cref="LegacyRosterKey"/>. Unlike
|
||||
/// <see cref="MigrateLegacyIfNeeded"/> — which only ever converts the
|
||||
/// ONE currently-selected route — this converts every OTHER named route
|
||||
/// the old roster still lists, using a fresh <see cref="NavigationSettings"/>
|
||||
/// per entry so the live/selected route is never touched. Named routes
|
||||
/// were never owner-scoped (one shared, globally-hashed key per name),
|
||||
/// so every entry converts regardless of which character is currently
|
||||
/// bound.
|
||||
/// </summary>
|
||||
private void SweepLegacyRosterIfNeeded()
|
||||
{
|
||||
string identity = (byCharacter ? "char:" : "named:")
|
||||
+ value.Trim().ToUpperInvariant();
|
||||
string hash = Convert.ToHexString(
|
||||
SHA256.HashData(Encoding.UTF8.GetBytes(identity)));
|
||||
return $"profiles/route/{hash}.json";
|
||||
if (_rosterSwept)
|
||||
return;
|
||||
_rosterSwept = true;
|
||||
if (!_host.Storage.IsAvailable)
|
||||
return;
|
||||
List<string>? names = ReadRosterNames();
|
||||
if (names is not { Count: > 0 })
|
||||
return;
|
||||
|
||||
var remaining = new List<string>();
|
||||
int migrated = 0;
|
||||
foreach (string rawName in names)
|
||||
{
|
||||
string name = (rawName ?? string.Empty).Trim();
|
||||
if (name.Length == 0 || name.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase))
|
||||
continue; // stale/invalid row; drop it rather than loop on it forever.
|
||||
|
||||
string legacyKey = LegacyProfileKey(name, byCharacter: false);
|
||||
LegacyRouteDocument? legacy = ReadLegacyJson(legacyKey);
|
||||
if (legacy is null)
|
||||
continue; // already converted (or never existed); drop the row.
|
||||
|
||||
string fileName = ToFileName(name);
|
||||
if (VtankStorage.IsAvailable && VtankStorage.ReadText(fileName) is null)
|
||||
{
|
||||
var scratch = new NavigationSettings();
|
||||
legacy.ApplyRouteOnly(scratch);
|
||||
WriteAf(fileName, MetafSerializer.SaveNav(scratch));
|
||||
}
|
||||
_host.Storage.Delete(legacyKey);
|
||||
migrated++;
|
||||
}
|
||||
|
||||
if (migrated == 0)
|
||||
return;
|
||||
|
||||
if (remaining.Count > 0)
|
||||
{
|
||||
_host.Storage.WriteText(
|
||||
LegacyRosterKey,
|
||||
JsonSerializer.Serialize(new LegacyRosterDocument { Names = remaining }, JsonOptions));
|
||||
}
|
||||
else
|
||||
{
|
||||
_host.Storage.Delete(LegacyRosterKey);
|
||||
}
|
||||
_host.Log.Warn($"Migrated {migrated} legacy MossTank named route profile(s) from the old roster.");
|
||||
}
|
||||
|
||||
private string SelectionKey() => string.IsNullOrWhiteSpace(_characterName)
|
||||
? "_default"
|
||||
: _characterName;
|
||||
private List<string>? ReadRosterNames()
|
||||
{
|
||||
string? json = null;
|
||||
try
|
||||
{
|
||||
json = _host.Storage.ReadText(LegacyRosterKey);
|
||||
return string.IsNullOrWhiteSpace(json)
|
||||
? null
|
||||
: JsonSerializer.Deserialize<LegacyRosterDocument>(json, JsonOptions)?.Names;
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
RecoveryNotice = MossTankProfileRecovery.Preserve(_host, "route", LegacyRosterKey, json, error);
|
||||
_host.Log.Warn(RecoveryNotice);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private T? Read<T>(string key) where T : class
|
||||
private void MigrateLegacyIfNeeded(NavigationSettings target, ISpellCatalog spells)
|
||||
{
|
||||
string fileName = CurrentFileName();
|
||||
if (!VtankStorage.IsAvailable || VtankStorage.ReadText(fileName) is not null)
|
||||
{
|
||||
_pendingLegacyBareName = null;
|
||||
return;
|
||||
}
|
||||
string legacyKey = _pendingLegacyBareName is { Length: > 0 } bareName
|
||||
? LegacyProfileKey(bareName, byCharacter: false)
|
||||
: LegacyProfileKey(_characterName, byCharacter: true);
|
||||
LegacyRouteDocument? legacy = ReadLegacyJson(legacyKey);
|
||||
if (legacy is null)
|
||||
{
|
||||
_pendingLegacyBareName = null;
|
||||
return;
|
||||
}
|
||||
legacy.ApplyRouteOnly(target);
|
||||
WriteAf(fileName, MetafSerializer.SaveNav(target));
|
||||
if (_host.Storage.IsAvailable)
|
||||
_host.Storage.Delete(legacyKey);
|
||||
_pendingLegacyBareName = null;
|
||||
_host.Log.Warn($"Migrated legacy MossTank route profile '{legacyKey}' to '{fileName}'.");
|
||||
}
|
||||
|
||||
private LegacyRouteDocument? ReadLegacyJson(string key)
|
||||
{
|
||||
if (!_host.Storage.IsAvailable)
|
||||
return null;
|
||||
|
|
@ -229,28 +385,56 @@ internal sealed class MossTankRouteProfileStore
|
|||
json = _host.Storage.ReadText(key);
|
||||
return string.IsNullOrWhiteSpace(json)
|
||||
? null
|
||||
: JsonSerializer.Deserialize<T>(json, Options);
|
||||
: JsonSerializer.Deserialize<LegacyRouteDocument>(json, JsonOptions);
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
RecoveryNotice = MossTankProfileRecovery.Preserve(
|
||||
_host,
|
||||
"route",
|
||||
key,
|
||||
json,
|
||||
error);
|
||||
RecoveryNotice = MossTankProfileRecovery.Preserve(_host, "route", key, json, error);
|
||||
_host.Log.Warn(RecoveryNotice);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private void Write<T>(string key, T document)
|
||||
// ------------------------------------------------------------------
|
||||
// File naming, storage plumbing.
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
// Round 3 item 3: the hidden "--" prefix MUST come before the "nav_"
|
||||
// kind marker (--nav_Name_Server.af) — putting the marker first
|
||||
// (nav_--Name_Server.af, the pre-fix shape) means the filename does not
|
||||
// start with "--" at all, defeating the StartsWith("--") hidden-file
|
||||
// check both ListNavigationProfiles and ListMetaProfiles rely on and
|
||||
// leaking this character's private per-character route to every other
|
||||
// character's picker.
|
||||
private string CurrentFileName() => _selected.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase)
|
||||
? VtankProfileDirectory.AutoCharacterFileName(_characterName, Server, "af", NavPrefix)
|
||||
: _selected;
|
||||
|
||||
private static string ToFileName(string bareName) =>
|
||||
NavPrefix + bareName + ".af";
|
||||
|
||||
private void WriteBinding()
|
||||
{
|
||||
if (!_host.Storage.IsAvailable)
|
||||
if (!CanBindFiles || !VtankStorage.IsAvailable)
|
||||
return;
|
||||
VtankProfileDirectory.VtankCharacterBinding existing =
|
||||
VtankProfileDirectory.TryReadCharacterBinding(VtankStorage, _characterName, Server)
|
||||
?? new VtankProfileDirectory.VtankCharacterBinding(
|
||||
string.Empty, string.Empty, CurrentFileName(), null);
|
||||
VtankProfileDirectory.WriteCharacterBinding(
|
||||
VtankStorage,
|
||||
_characterName,
|
||||
Server,
|
||||
existing with { NavFileName = CurrentFileName() });
|
||||
}
|
||||
|
||||
private void WriteAf(string fileName, string text)
|
||||
{
|
||||
if (!VtankStorage.IsAvailable)
|
||||
return;
|
||||
try
|
||||
{
|
||||
_host.Storage.WriteText(key, JsonSerializer.Serialize(document, Options));
|
||||
VtankStorage.WriteText(fileName, text);
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
|
|
@ -258,47 +442,35 @@ internal sealed class MossTankRouteProfileStore
|
|||
}
|
||||
}
|
||||
|
||||
private void SaveIndex() => Write(IndexKey, _index);
|
||||
private static string Normalize(string? name) => name?.Trim() ?? string.Empty;
|
||||
|
||||
private void WriteLegacyExport(string name, NavigationSettings settings)
|
||||
private static string LegacyProfileKey(string value, bool byCharacter)
|
||||
{
|
||||
if (!_host.Storage.IsAvailable)
|
||||
return;
|
||||
try
|
||||
{
|
||||
_host.Storage.WriteText(
|
||||
$"exports/{LegacyFileName(name)}.nav",
|
||||
VtankNavRouteSerializer.Save(settings));
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
_host.Log.Warn(
|
||||
$"MossTank VTank navigation export could not be saved: {error.Message}");
|
||||
}
|
||||
string identity = (byCharacter ? "char:" : "named:") + value.Trim().ToUpperInvariant();
|
||||
string hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(identity)));
|
||||
return $"profiles/route/{hash}.json";
|
||||
}
|
||||
|
||||
private static string LegacyFileName(string name)
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
char[] invalid = Path.GetInvalidFileNameChars();
|
||||
var result = new StringBuilder(name.Length);
|
||||
foreach (char value in name.Trim())
|
||||
{
|
||||
result.Append(value is '/' or '\\' || invalid.Contains(value)
|
||||
? '_'
|
||||
: value);
|
||||
}
|
||||
return result.Length == 0 ? "Route" : result.ToString();
|
||||
}
|
||||
WriteIndented = true,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
};
|
||||
|
||||
private sealed class IndexDocument
|
||||
// Read-only: the pre-cutover roster shape at LegacyRosterKey, kept
|
||||
// solely so SweepLegacyRosterIfNeeded can recover it once.
|
||||
private sealed class LegacyRosterDocument
|
||||
{
|
||||
public int Version { get; set; } = 1;
|
||||
public List<string> Names { get; set; } = [];
|
||||
public Dictionary<string, string> SelectedByCharacter { get; set; } =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private sealed class RouteDocument
|
||||
// ------------------------------------------------------------------
|
||||
// Migration-only: the OLD full route document shape, kept solely so a
|
||||
// not-yet-migrated JSON profile can still be read once and converted.
|
||||
// Only the fields this store still owns (Mode/Waypoints/FollowTarget)
|
||||
// are applied — the rest belonged to the Settings profile even before
|
||||
// the cutover and are left to whatever that profile already holds.
|
||||
private sealed class LegacyRouteDocument
|
||||
{
|
||||
public int Version { get; set; } = 1;
|
||||
public bool Enabled { get; set; }
|
||||
|
|
@ -312,50 +484,20 @@ internal sealed class MossTankRouteProfileStore
|
|||
public double DoorIdentifyRangeMeters { get; set; } = 20d;
|
||||
public double DoorOpenRangeMeters { get; set; } = 4d;
|
||||
public int DoorLockpickExcessThreshold { get; set; } = -50;
|
||||
public WaypointDocument[] Waypoints { get; set; } = [];
|
||||
public LegacyWaypointDocument[] Waypoints { get; set; } = [];
|
||||
|
||||
public static RouteDocument Capture(NavigationSettings value) => new()
|
||||
public void ApplyRouteOnly(NavigationSettings value)
|
||||
{
|
||||
Enabled = value.Enabled,
|
||||
Priority = value.Priority,
|
||||
Mode = value.Mode,
|
||||
MinimumDistanceMeters = value.MinimumDistanceMeters,
|
||||
FollowTargetObjectId = value.FollowTargetObjectId,
|
||||
FollowTargetName = value.FollowTargetName,
|
||||
FollowAroundCorners = value.FollowAroundCorners,
|
||||
OpenDoors = value.OpenDoors,
|
||||
DoorIdentifyRangeMeters = value.DoorIdentifyRangeMeters,
|
||||
DoorOpenRangeMeters = value.DoorOpenRangeMeters,
|
||||
DoorLockpickExcessThreshold = value.DoorLockpickExcessThreshold,
|
||||
Waypoints = value.Waypoints.Select(WaypointDocument.From).ToArray(),
|
||||
};
|
||||
|
||||
public void Apply(NavigationSettings value)
|
||||
{
|
||||
value.Enabled = Enabled;
|
||||
value.Priority = Priority;
|
||||
value.Mode = Enum.IsDefined(Mode) ? Mode : RouteMode.Circular;
|
||||
value.MinimumDistanceMeters = Math.Clamp(
|
||||
MinimumDistanceMeters,
|
||||
0.5d,
|
||||
50d);
|
||||
value.FollowTargetObjectId = FollowTargetObjectId;
|
||||
value.FollowTargetName = FollowTargetName ?? string.Empty;
|
||||
value.FollowAroundCorners = FollowAroundCorners;
|
||||
value.OpenDoors = OpenDoors;
|
||||
value.DoorIdentifyRangeMeters = Math.Clamp(
|
||||
DoorIdentifyRangeMeters, 1d, 100d);
|
||||
value.DoorOpenRangeMeters = Math.Clamp(
|
||||
DoorOpenRangeMeters, 0.5d, value.DoorIdentifyRangeMeters);
|
||||
value.DoorLockpickExcessThreshold = Math.Clamp(
|
||||
DoorLockpickExcessThreshold, -500, 500);
|
||||
value.Waypoints.Clear();
|
||||
foreach (WaypointDocument waypoint in Waypoints ?? [])
|
||||
foreach (LegacyWaypointDocument waypoint in Waypoints ?? [])
|
||||
value.Waypoints.Add(waypoint.ToWaypoint());
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class WaypointDocument
|
||||
private sealed class LegacyWaypointDocument
|
||||
{
|
||||
public RouteWaypointType Type { get; set; }
|
||||
public uint CellId { get; set; }
|
||||
|
|
@ -364,6 +506,12 @@ internal sealed class MossTankRouteProfileStore
|
|||
public double Elevation { get; set; }
|
||||
public float HeadingDegrees { get; set; }
|
||||
public bool IsOutdoor { get; set; }
|
||||
public uint ReferenceCellId { get; set; }
|
||||
public double ReferenceEastWest { get; set; }
|
||||
public double ReferenceNorthSouth { get; set; }
|
||||
public double ReferenceElevation { get; set; }
|
||||
public float ReferenceHeadingDegrees { get; set; }
|
||||
public bool ReferenceIsOutdoor { get; set; }
|
||||
public uint ObjectId { get; set; }
|
||||
public string ObjectName { get; set; } = string.Empty;
|
||||
public int LegacyObjectClass { get; set; }
|
||||
|
|
@ -378,40 +526,18 @@ internal sealed class MossTankRouteProfileStore
|
|||
public int JumpChargeMilliseconds { get; set; } = 1000;
|
||||
public RouteJumpDirection JumpDirection { get; set; }
|
||||
|
||||
public static WaypointDocument From(RouteWaypoint value) => new()
|
||||
{
|
||||
Type = value.Type,
|
||||
CellId = value.Position.CellId,
|
||||
EastWest = value.Position.EastWest,
|
||||
NorthSouth = value.Position.NorthSouth,
|
||||
Elevation = value.Position.Elevation,
|
||||
HeadingDegrees = value.Position.HeadingDegrees,
|
||||
IsOutdoor = value.Position.IsOutdoor,
|
||||
ObjectId = value.ObjectId,
|
||||
ObjectName = value.ObjectName,
|
||||
LegacyObjectClass = value.LegacyObjectClass,
|
||||
LegacyReferenceValid = value.LegacyReferenceValid,
|
||||
Text = value.Text,
|
||||
DurationMilliseconds = value.DurationMilliseconds,
|
||||
Recall = value.Recall,
|
||||
RecallSpellId = value.RecallSpellId,
|
||||
RecallSpellName = value.RecallSpellName,
|
||||
JumpHeadingDegrees = value.JumpHeadingDegrees,
|
||||
JumpRun = value.JumpRun,
|
||||
JumpChargeMilliseconds = value.JumpChargeMilliseconds,
|
||||
JumpDirection = value.JumpDirection,
|
||||
};
|
||||
|
||||
public RouteWaypoint ToWaypoint() => new()
|
||||
{
|
||||
Type = Enum.IsDefined(Type) ? Type : RouteWaypointType.Point,
|
||||
Position = new PluginNavigationPosition(
|
||||
CellId,
|
||||
EastWest,
|
||||
NorthSouth,
|
||||
Elevation,
|
||||
HeadingDegrees,
|
||||
IsOutdoor),
|
||||
CellId, EastWest, NorthSouth, Elevation, HeadingDegrees, IsOutdoor),
|
||||
ReferencePosition = new PluginNavigationPosition(
|
||||
ReferenceCellId,
|
||||
ReferenceEastWest,
|
||||
ReferenceNorthSouth,
|
||||
ReferenceElevation,
|
||||
ReferenceHeadingDegrees,
|
||||
ReferenceIsOutdoor),
|
||||
ObjectId = ObjectId,
|
||||
ObjectName = ObjectName ?? string.Empty,
|
||||
LegacyObjectClass = LegacyObjectClass,
|
||||
|
|
@ -421,17 +547,15 @@ internal sealed class MossTankRouteProfileStore
|
|||
Recall = Enum.IsDefined(Recall) ? Recall : RouteRecallKind.Lifestone,
|
||||
RecallSpellId = RecallSpellId,
|
||||
RecallSpellName = RecallSpellName ?? string.Empty,
|
||||
JumpHeadingDegrees = float.IsFinite(JumpHeadingDegrees)
|
||||
? JumpHeadingDegrees
|
||||
: 0f,
|
||||
JumpHeadingDegrees = float.IsFinite(JumpHeadingDegrees) ? JumpHeadingDegrees : 0f,
|
||||
JumpRun = JumpRun,
|
||||
JumpChargeMilliseconds = Math.Clamp(
|
||||
JumpChargeMilliseconds,
|
||||
0,
|
||||
10_000),
|
||||
JumpDirection = Enum.IsDefined(JumpDirection)
|
||||
? JumpDirection
|
||||
: RouteJumpDirection.Forward,
|
||||
// Round 3 item 5: no load-time clamp here either — the real
|
||||
// bi.a 2000 ms ceiling is retail's EXECUTION-time behavior
|
||||
// (NavigationController.TickJump), not a storage-format limit;
|
||||
// this legacy migration path preserves whatever the pre-cutover
|
||||
// JSON authored, exactly like the .af load path now does.
|
||||
JumpChargeMilliseconds = JumpChargeMilliseconds,
|
||||
JumpDirection = Enum.IsDefined(JumpDirection) ? JumpDirection : RouteJumpDirection.Forward,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,6 +44,17 @@ internal sealed class RouteWaypoint
|
|||
{
|
||||
public RouteWaypointType Type { get; set; }
|
||||
public PluginNavigationPosition Position { get; set; }
|
||||
/// <summary>
|
||||
/// The second of the two coordinate triples VTank's own Portal2/UseNPC
|
||||
/// waypoint records carry (metaf's "ptl"/"tlk" nodes: "myx myy myz tgtx
|
||||
/// tgty tgtz" — <c>metaf_monolithic.py:356-357,11482,11618</c>; the
|
||||
/// binary <c>.nav</c> record's own trailing xyz). <see cref="Position"/>
|
||||
/// is where the bot stands to use the object ("myxyz"); this is the
|
||||
/// portal/NPC object's own recorded position ("objxyz"), used to
|
||||
/// disambiguate which object at that name to interact with. Unused for
|
||||
/// every other waypoint type.
|
||||
/// </summary>
|
||||
public PluginNavigationPosition ReferencePosition { get; set; }
|
||||
public uint ObjectId { get; set; }
|
||||
public string ObjectName { get; set; } = string.Empty;
|
||||
/// <summary>Decal ObjectClass retained for exact VTank NAV interchange.</summary>
|
||||
|
|
@ -64,6 +75,7 @@ internal sealed class RouteWaypoint
|
|||
{
|
||||
Type = Type,
|
||||
Position = Position,
|
||||
ReferencePosition = ReferencePosition,
|
||||
ObjectId = ObjectId,
|
||||
ObjectName = ObjectName,
|
||||
LegacyObjectClass = LegacyObjectClass,
|
||||
|
|
@ -185,6 +197,13 @@ internal sealed class NavigationController
|
|||
private const double RecallExitDistanceMeters = 2.4d;
|
||||
private const double JumpLaunchGraceSeconds = 0.25d;
|
||||
private const double JumpCompletionTimeoutSeconds = 3d;
|
||||
// Round 3 item 5: retail's real jump-charge ceiling
|
||||
// (refs/vtank/decompiled/bi.cs:502-505, bi.a — "if (A_2 > 2000.0) A_2 =
|
||||
// 2000.0" at the moment the jump starts charging) belongs HERE, at
|
||||
// execution, not at .af/legacy-JSON load — a waypoint's authored
|
||||
// JumpChargeMilliseconds round-trips through storage unclamped; only
|
||||
// the actual in-game charge duration is capped.
|
||||
private const int JumpChargeCeilingMilliseconds = 2000;
|
||||
private const double CheckpointRetrySeconds = 15d;
|
||||
private const double FollowBreadcrumbSpacingMeters = 0.096d;
|
||||
private const double FollowPathCaptureRangeMeters = 240d;
|
||||
|
|
@ -711,9 +730,15 @@ internal sealed class NavigationController
|
|||
StringComparison.OrdinalIgnoreCase));
|
||||
if (!currentStillExists)
|
||||
{
|
||||
// Search near the object's own recorded position ("tgtxyz"
|
||||
// in metaf's ptl/tlk grammar), not where the bot was
|
||||
// standing when the waypoint was authored ("myxyz" —
|
||||
// waypoint.Position): the object can be meaningfully far
|
||||
// from the approach point (e.g. a portal at the far end of
|
||||
// a room).
|
||||
if (!_host.Automation.Navigation.TryFindObject(
|
||||
waypoint.ObjectName,
|
||||
waypoint.Position,
|
||||
waypoint.ReferencePosition,
|
||||
ObjectReacquireRadiusMeters,
|
||||
out PluginNavigationObject reacquired))
|
||||
{
|
||||
|
|
@ -941,15 +966,16 @@ internal sealed class NavigationController
|
|||
}
|
||||
|
||||
_jumpChargeElapsed += elapsedSeconds;
|
||||
bool hold = _jumpChargeElapsed * 1000d
|
||||
< Math.Max(0, waypoint.JumpChargeMilliseconds);
|
||||
int effectiveChargeMilliseconds = Math.Clamp(
|
||||
waypoint.JumpChargeMilliseconds, 0, JumpChargeCeilingMilliseconds);
|
||||
bool hold = _jumpChargeElapsed * 1000d < effectiveChargeMilliseconds;
|
||||
if (hold)
|
||||
{
|
||||
PluginMovementIntent intent = JumpIntent(waypoint, jump: true);
|
||||
_hadMovementIntent = _host.Automation.Navigation
|
||||
.SetMovementIntent(intent)
|
||||
== PluginNavigationCommandStatus.Accepted;
|
||||
_status = $"Charging jump: {waypoint.JumpChargeMilliseconds}ms.";
|
||||
_status = $"Charging jump: {effectiveChargeMilliseconds}ms.";
|
||||
return true;
|
||||
}
|
||||
PluginMovementIntent release = JumpIntent(waypoint, jump: false);
|
||||
|
|
|
|||
|
|
@ -117,9 +117,9 @@ internal sealed class PetAutomation
|
|||
if (!settings.SummonPets || activeOwnedPetCount > 0)
|
||||
return PetAutomationChoice.None;
|
||||
|
||||
float range = settings.PetRangeMode == PetRangeMode.Custom
|
||||
float range = (float)(settings.PetRangeMode == PetRangeMode.Custom
|
||||
? settings.PetCustomRange
|
||||
: settings.MaximumRange;
|
||||
: settings.MaximumRange);
|
||||
int density = Math.Max(1, settings.PetMonsterDensity);
|
||||
var eligible = new List<(PluginCombatTarget Target, ResolvedMonsterRule Rule)>();
|
||||
foreach (PluginCombatTarget target in targets)
|
||||
|
|
|
|||
|
|
@ -35,9 +35,11 @@ public sealed class VitalSettings
|
|||
public double HelperHealth { get; set; } = 0.20;
|
||||
public double HelperStamina { get; set; } = 0.01;
|
||||
public double HelperMana { get; set; } = 0.01;
|
||||
public float HelperHealthDistance { get; set; } = 59.6f;
|
||||
public float HelperStaminaDistance { get; set; } = 59.6f;
|
||||
public float HelperManaDistance { get; set; } = 32f;
|
||||
// Declared tDouble in VTank's own Settings table (item I, slice-1 fix
|
||||
// round) — see CombatSettings.MaximumRange's doc comment for why.
|
||||
public double HelperHealthDistance { get; set; } = 59.6d;
|
||||
public double HelperStaminaDistance { get; set; } = 59.6d;
|
||||
public double HelperManaDistance { get; set; } = 32d;
|
||||
|
||||
public bool HelpOthers { get; set; } = true;
|
||||
public bool UseHealersHeart { get; set; } = true;
|
||||
|
|
@ -46,6 +48,19 @@ public sealed class VitalSettings
|
|||
public bool ClearLevelBoostFlagOnCast { get; set; } = true;
|
||||
public int DropToPeaceModeRetryCount { get; set; } = 34;
|
||||
public string RechargeHandlerSet { get; set; } = "RechargeHandlerSet";
|
||||
/// <summary>
|
||||
/// The real VTank <c>RechargeHandlerSet</c> nested table (5 columns:
|
||||
/// Vital/HandlerString/MinPercent/MaxPercent/Stance). Defaults to VTank's
|
||||
/// own shipped 26-row table (<see cref="VtankDefaultSettingsDatabase.DefaultRechargeHandlerRows"/>),
|
||||
/// not an independently hand-typed replica; loading a real <c>.usd</c>
|
||||
/// profile via <see cref="VtankSettingsProfileSerializer"/> replaces it
|
||||
/// with that profile's own table. <see cref="VitalRechargePlanner"/>'s
|
||||
/// hand-ported switch-based ordering is retained only as a defensive
|
||||
/// fallback for the case where every row happens to have been cleared
|
||||
/// explicitly and none match a given (vital, stance, percent) band.
|
||||
/// </summary>
|
||||
public IReadOnlyList<RechargeHandlerRow> RechargeHandlerRows { get; set; } =
|
||||
VtankDefaultSettingsDatabase.DefaultRechargeHandlerRows;
|
||||
public bool UseKitsInMagicMode { get; set; } = true;
|
||||
public bool GoToPeaceModeToUseKits { get; set; }
|
||||
public int MinimumHealKitSuccessChance { get; set; } = 95;
|
||||
|
|
|
|||
|
|
@ -33,6 +33,43 @@ internal enum VitalRechargeMethod
|
|||
Food,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One row of VTank's real <c>RechargeHandlerSet</c> nested table
|
||||
/// (<c>refs/vtank/uTank2.Resources.defaultsettings.usd</c>, Settings row
|
||||
/// 137's Value cell — a 5-column TABLE: Vital/HandlerString/MinPercent/
|
||||
/// MaxPercent/Stance, 26 seed rows verified against the live fixture).
|
||||
/// <c>Vital</c> uses VTank's own raw encoding (1=Health, 2=Stamina,
|
||||
/// 3=Mana — distinct from this port's <see cref="VitalKind"/> which reuses
|
||||
/// AC property-id values); <c>Stance</c> is 1=Magic, 2=Melee/Missile
|
||||
/// (matches this planner's <c>magicMode</c> bool exactly, confirmed by
|
||||
/// reproducing every existing hand-ported default list from the parsed
|
||||
/// table). <c>MinPercent</c>/<c>MaxPercent</c> bound the current-vital
|
||||
/// percentage this row applies at.
|
||||
/// </summary>
|
||||
public readonly record struct RechargeHandlerRow(
|
||||
int Vital,
|
||||
string HandlerString,
|
||||
int MinPercent,
|
||||
int MaxPercent,
|
||||
int Stance)
|
||||
{
|
||||
internal static VitalKind? ToVitalKind(int vital) => vital switch
|
||||
{
|
||||
1 => VitalKind.Health,
|
||||
2 => VitalKind.Stamina,
|
||||
3 => VitalKind.Mana,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
internal static int FromVitalKind(VitalKind vital) => vital switch
|
||||
{
|
||||
VitalKind.Health => 1,
|
||||
VitalKind.Stamina => 2,
|
||||
VitalKind.Mana => 3,
|
||||
_ => 0,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// VTank's default <c>RechargeHandlerSet</c>, including its stance- and
|
||||
/// current-percentage-dependent order. The host supplies raw inventory and
|
||||
|
|
@ -60,7 +97,10 @@ internal static class VitalRechargePlanner
|
|||
vital,
|
||||
mode == PluginCombatMode.Magic,
|
||||
percent,
|
||||
settings.RechargeHandlerSet);
|
||||
settings.RechargeHandlerSet,
|
||||
settings.RechargeHandlerRows.Count != 0
|
||||
? settings.RechargeHandlerRows
|
||||
: null);
|
||||
IReadOnlyList<PluginInventoryItem> items =
|
||||
automation.Items.CaptureOwnedItems();
|
||||
|
||||
|
|
@ -107,7 +147,7 @@ internal static class VitalRechargePlanner
|
|||
|
||||
IReadOnlyList<PluginFellowMember> members =
|
||||
automation.Fellowship.CaptureMembers();
|
||||
foreach ((VitalKind vital, double threshold, float distance, uint baseSpell)
|
||||
foreach ((VitalKind vital, double threshold, double distance, uint baseSpell)
|
||||
in new[]
|
||||
{
|
||||
(VitalKind.Health, settings.HelperHealth,
|
||||
|
|
@ -118,7 +158,7 @@ internal static class VitalRechargePlanner
|
|||
settings.HelperManaDistance, (uint)SpellId.GiftOfEssence),
|
||||
})
|
||||
{
|
||||
PluginFellowMember? target = Lowest(members, vital, threshold, distance);
|
||||
PluginFellowMember? target = Lowest(members, vital, threshold, (float)distance);
|
||||
if (target is not { } fellow)
|
||||
continue;
|
||||
if (vital == VitalKind.Health
|
||||
|
|
@ -205,12 +245,36 @@ internal static class VitalRechargePlanner
|
|||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <paramref name="handlerRows"/> is <see cref="VitalSettings.RechargeHandlerRows"/>,
|
||||
/// which defaults to VTank's own shipped 26-row table
|
||||
/// (<see cref="VtankDefaultSettingsDatabase.DefaultRechargeHandlerRows"/>)
|
||||
/// rather than an empty list, so <see cref="TryHandlersFromRows"/> is the
|
||||
/// path every ordinary call takes. The hardcoded <c>defaults</c> switch
|
||||
/// below is retained ONLY as a defensive net for a caller that
|
||||
/// deliberately passes an empty/null row set or an unrecognized
|
||||
/// (vital, stance, percent) band the real table happens not to cover —
|
||||
/// not as the primary source of truth it used to be before the real
|
||||
/// table became parseable.
|
||||
/// </summary>
|
||||
internal static IReadOnlyList<VitalRechargeMethod> Handlers(
|
||||
VitalKind vital,
|
||||
bool magicMode,
|
||||
int currentPercent,
|
||||
string? handlerSet = null)
|
||||
string? handlerSet = null,
|
||||
IReadOnlyList<RechargeHandlerRow>? handlerRows = null)
|
||||
{
|
||||
if (handlerRows is { Count: > 0 }
|
||||
&& TryHandlersFromRows(
|
||||
vital,
|
||||
magicMode,
|
||||
currentPercent,
|
||||
handlerRows,
|
||||
out VitalRechargeMethod[] fromRows))
|
||||
{
|
||||
return fromRows;
|
||||
}
|
||||
|
||||
IReadOnlyList<VitalRechargeMethod> defaults;
|
||||
if (magicMode)
|
||||
{
|
||||
|
|
@ -288,6 +352,81 @@ internal static class VitalRechargePlanner
|
|||
: defaults;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// VTank's real table-driven handler-order lookup: collect every row
|
||||
/// matching (Vital, Stance) whose [MinPercent,MaxPercent] band contains
|
||||
/// the current percentage, IN FILE ORDER (duplicates included — VTank's
|
||||
/// own shipped table has them, e.g. row 17 of the default table repeats
|
||||
/// "Recharge With Food" for the non-magic Health band; a duplicate is
|
||||
/// harmless, since re-trying the same handler a second time just fails
|
||||
/// identically). Confirmed against every hand-ported default list this
|
||||
/// planner shipped before this table was parseable — see
|
||||
/// <c>docs/research/vtank-kb/01-settings-and-profiles.md</c> row 137 and
|
||||
/// the RechargeHandlerSet fixture. Unknown HandlerString tokens are
|
||||
/// skipped (forward-compatible with a future VTank build's new method).
|
||||
/// </summary>
|
||||
private static bool TryHandlersFromRows(
|
||||
VitalKind vital,
|
||||
bool magicMode,
|
||||
int currentPercent,
|
||||
IReadOnlyList<RechargeHandlerRow> rows,
|
||||
out VitalRechargeMethod[] handlers)
|
||||
{
|
||||
int vitalCode = RechargeHandlerRow.FromVitalKind(vital);
|
||||
int stance = magicMode ? 1 : 2;
|
||||
var matched = new List<VitalRechargeMethod>();
|
||||
foreach (RechargeHandlerRow row in rows)
|
||||
{
|
||||
if (row.Vital != vitalCode
|
||||
|| row.Stance != stance
|
||||
|| currentPercent < row.MinPercent
|
||||
|| currentPercent > row.MaxPercent)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (TryParseHandlerToken(row.HandlerString, out VitalRechargeMethod method))
|
||||
matched.Add(method);
|
||||
}
|
||||
handlers = [.. matched];
|
||||
return handlers.Length != 0;
|
||||
}
|
||||
|
||||
private static bool TryParseHandlerToken(string source, out VitalRechargeMethod method)
|
||||
{
|
||||
string normalized = source.Replace(" ", string.Empty, StringComparison.Ordinal)
|
||||
.Replace("-", string.Empty, StringComparison.Ordinal)
|
||||
.ToLowerInvariant();
|
||||
switch (normalized)
|
||||
{
|
||||
case "regularspell":
|
||||
method = VitalRechargeMethod.RegularSpell;
|
||||
return true;
|
||||
case "staminatohealth":
|
||||
method = VitalRechargeMethod.StaminaToHealth;
|
||||
return true;
|
||||
case "manatohealth":
|
||||
method = VitalRechargeMethod.ManaToHealth;
|
||||
return true;
|
||||
case "healthtostamina":
|
||||
method = VitalRechargeMethod.HealthToStamina;
|
||||
return true;
|
||||
case "healthtomana":
|
||||
method = VitalRechargeMethod.HealthToMana;
|
||||
return true;
|
||||
case "kit":
|
||||
case "kitrecharge":
|
||||
method = VitalRechargeMethod.Kit;
|
||||
return true;
|
||||
case "food":
|
||||
case "rechargewithfood":
|
||||
method = VitalRechargeMethod.Food;
|
||||
return true;
|
||||
default:
|
||||
method = default;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string HandlerContext(
|
||||
VitalKind vital,
|
||||
bool magicMode,
|
||||
|
|
|
|||
2270
src/AcDream.Plugins.MossTank/VtankDefaultSettings.usd
Normal file
2270
src/AcDream.Plugins.MossTank/VtankDefaultSettings.usd
Normal file
File diff suppressed because it is too large
Load diff
67
src/AcDream.Plugins.MossTank/VtankDefaultSettingsDatabase.cs
Normal file
67
src/AcDream.Plugins.MossTank/VtankDefaultSettingsDatabase.cs
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
using System.Reflection;
|
||||
|
||||
namespace AcDream.Plugins.MossTank;
|
||||
|
||||
/// <summary>
|
||||
/// The complete <c>.usd</c> shipped by VTank as
|
||||
/// <c>uTank2.Resources.defaultsettings.usd</c> — every table (Settings,
|
||||
/// MyMonsters, GemFoodItems, ExtraBuffSpells, AntiExtraBuffSpells,
|
||||
/// ItemUseSpecifiers, SettingsCategories, SettingsEnumInfo, AssistItems,
|
||||
/// BuffedItems, RechargeHandlerSet, …), byte-for-byte, not a hand-typed
|
||||
/// C# replica. <see cref="VtankSettingsProfileSerializer.CreateNew"/> seeds
|
||||
/// a brand-new profile from this document (every table, every row's real
|
||||
/// <c>Description</c>/<c>SettingType</c>) and
|
||||
/// <see cref="VitalSettings"/>'s default <c>RechargeHandlerRows</c> is
|
||||
/// parsed from its <c>RechargeHandlerSet</c> table — both replace a prior
|
||||
/// port's independently hand-maintained defaults, which could (and did:
|
||||
/// see the 8fa70c3e4 "26 rows, not 24" fix) silently drift from what VTank
|
||||
/// itself ships.
|
||||
/// </summary>
|
||||
internal static class VtankDefaultSettingsDatabase
|
||||
{
|
||||
private const string ResourceSuffix = ".VtankDefaultSettings.usd";
|
||||
private static readonly Lazy<string> RawText = new(LoadText);
|
||||
private static readonly Lazy<RechargeHandlerRow[]> DefaultRows = new(LoadDefaultRows);
|
||||
|
||||
/// <summary>A fresh, independently mutable parse of the embedded document — every call gets its own object graph.</summary>
|
||||
public static VtankDatabase Parse() => VtankDatabase.Parse(RawText.Value);
|
||||
|
||||
/// <summary>VTank's own shipped <c>RechargeHandlerSet</c> rows (26, in file order).</summary>
|
||||
public static IReadOnlyList<RechargeHandlerRow> DefaultRechargeHandlerRows => DefaultRows.Value;
|
||||
|
||||
private static string LoadText()
|
||||
{
|
||||
Assembly assembly = typeof(VtankDefaultSettingsDatabase).Assembly;
|
||||
string resource = assembly.GetManifestResourceNames().Single(
|
||||
static name => name.EndsWith(ResourceSuffix, StringComparison.Ordinal));
|
||||
using Stream stream = assembly.GetManifestResourceStream(resource)
|
||||
?? throw new InvalidOperationException(
|
||||
"The embedded VTank default settings (.usd) document is missing.");
|
||||
using var reader = new StreamReader(stream);
|
||||
return reader.ReadToEnd();
|
||||
}
|
||||
|
||||
private static RechargeHandlerRow[] LoadDefaultRows()
|
||||
{
|
||||
VtankDatabase database = VtankDatabase.Parse(RawText.Value);
|
||||
VtankTable? settings = database.Find("Settings");
|
||||
if (settings is null)
|
||||
return [];
|
||||
int nameColumn = settings.ColumnIndex("Setting");
|
||||
int valueColumn = settings.ColumnIndex("Value");
|
||||
if (nameColumn < 0 || valueColumn < 0)
|
||||
return [];
|
||||
foreach (VtankRow row in settings.Rows)
|
||||
{
|
||||
if (!row.Cells[nameColumn].AsString().Equals(
|
||||
"RechargeHandlerSet", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
VtankCell cell = row.Cells[valueColumn];
|
||||
if (cell.Tag == "TABLE" && cell.Table is { } table)
|
||||
return VtankSettingsProfileSerializer.ParseRechargeHandlerSet(table);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
|
@ -23,20 +23,33 @@ internal static class VtankLootRequirementEvaluator
|
|||
[6095] = (28, 80),
|
||||
};
|
||||
|
||||
private static readonly IReadOnlyDictionary<uint, (uint Key, double Bonus)>
|
||||
DoubleSpellBonuses = new Dictionary<uint, (uint, double)>
|
||||
/// <summary>
|
||||
/// VTClassic's static double-spell-bonus table (<c>ComputedItemInfo.cs:88-139</c>).
|
||||
/// <c>Change</c> is the real per-row selector KB doc 05 section 2.2 names
|
||||
/// ("additive unless the static table's <c>Change==1</c>, in which case
|
||||
/// multiplicative", <c>ComputedItemInfo.cs:244</c>) — carried explicitly
|
||||
/// here rather than inferred from whether <c>Bonus</c>'s truncated
|
||||
/// integer part happens to equal 1 (a prior port's proxy, which this
|
||||
/// item replaced: it only worked because every multiplicative bonus in
|
||||
/// this table happens to be in [1.0, 2.0) and every additive one happens
|
||||
/// to be under 1.0 — a coincidence of the current 19 rows, not a rule,
|
||||
/// and it would have silently mis-branched on a future row like an
|
||||
/// additive 1.5 or a multiplicative 2.0+).
|
||||
/// </summary>
|
||||
private static readonly IReadOnlyDictionary<uint, (uint Key, double Bonus, bool Change)>
|
||||
DoubleSpellBonuses = new Dictionary<uint, (uint, double, bool)>
|
||||
{
|
||||
[3251] = (152, .01), [3250] = (152, .03),
|
||||
[4670] = (152, .05), [6098] = (152, .07),
|
||||
[2603] = (VtankDoubleBase + 12, .03),
|
||||
[2591] = (VtankDoubleBase + 12, .05),
|
||||
[4666] = (VtankDoubleBase + 12, .07),
|
||||
[6094] = (VtankDoubleBase + 12, .09),
|
||||
[2600] = (29, .03), [3985] = (29, .04),
|
||||
[2588] = (29, .05), [4663] = (29, .07), [6091] = (29, .09),
|
||||
[3201] = (144, 1.05), [3199] = (144, 1.10),
|
||||
[3202] = (144, 1.15), [3200] = (144, 1.20),
|
||||
[6086] = (144, 1.25), [6087] = (144, 1.30),
|
||||
[3251] = (152, .01, false), [3250] = (152, .03, false),
|
||||
[4670] = (152, .05, false), [6098] = (152, .07, false),
|
||||
[2603] = (VtankDoubleBase + 12, .03, false),
|
||||
[2591] = (VtankDoubleBase + 12, .05, false),
|
||||
[4666] = (VtankDoubleBase + 12, .07, false),
|
||||
[6094] = (VtankDoubleBase + 12, .09, false),
|
||||
[2600] = (29, .03, false), [3985] = (29, .04, false),
|
||||
[2588] = (29, .05, false), [4663] = (29, .07, false), [6091] = (29, .09, false),
|
||||
[3201] = (144, 1.05, true), [3199] = (144, 1.10, true),
|
||||
[3202] = (144, 1.15, true), [3200] = (144, 1.20, true),
|
||||
[6086] = (144, 1.25, true), [6087] = (144, 1.30, true),
|
||||
};
|
||||
|
||||
private static readonly IReadOnlyDictionary<string, int[]> ArmorColorSlots =
|
||||
|
|
@ -363,54 +376,103 @@ internal static class VtankLootRequirementEvaluator
|
|||
: string.Empty,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// The single source of truth for both "what is this int-typed key's
|
||||
/// value" (<see cref="IntValue"/>) and "does this item actually carry a
|
||||
/// base value for this key at all" (<see cref="IntKeyExists"/>) — item J
|
||||
/// (slice-1 fix round) consolidated these from two independently
|
||||
/// hand-maintained key lists that had to be kept in exact sync by hand:
|
||||
/// a named field added to one switch and forgotten in the other would
|
||||
/// silently let <c>BuffedInt</c>'s KeyExists gate treat a real,
|
||||
/// always-present field as "raw property bag only", which for a key the
|
||||
/// item's bag never happens to carry would wrongly refuse a spell bonus
|
||||
/// it should apply. Returns <see langword="false"/> only when
|
||||
/// <paramref name="key"/> is not one of the named
|
||||
/// <see cref="PluginInventoryItem"/> fields AND the raw property bag
|
||||
/// does not carry it either.
|
||||
/// </summary>
|
||||
private static bool TryIntValue(
|
||||
uint key,
|
||||
in PluginInventoryItem item,
|
||||
in PluginItemProperties properties,
|
||||
out int value)
|
||||
{
|
||||
switch (key)
|
||||
{
|
||||
case 5: value = item.Burden; return true;
|
||||
case 19: value = item.Value; return true;
|
||||
case 105: value = checked((int)item.Workmanship); return true;
|
||||
case 107: value = item.ItemCurrentMana; return true;
|
||||
case 108: value = item.ItemMaximumMana; return true;
|
||||
case 131: value = checked((int)item.MaterialType); return true;
|
||||
case VtankIntBase + 0: value = checked((int)item.WeenieClassId); return true;
|
||||
case VtankIntBase + 2: value = checked((int)item.ContainerObjectId); return true;
|
||||
case VtankIntBase + 4: value = item.ItemsCapacity; return true;
|
||||
case VtankIntBase + 5: value = item.ContainersCapacity; return true;
|
||||
case VtankIntBase + 6: value = item.StackSize; return true;
|
||||
case VtankIntBase + 7: value = item.MaximumStackSize; return true;
|
||||
case VtankIntBase + 8: value = checked((int)item.SpellId); return true;
|
||||
case VtankIntBase + 9: value = item.ContainerSlot; return true;
|
||||
case VtankIntBase + 10: value = checked((int)item.WielderObjectId); return true;
|
||||
case VtankIntBase + 11: value = checked((int)item.EquippedLocation); return true;
|
||||
case VtankIntBase + 14: value = checked((int)item.ValidLocations); return true;
|
||||
case VtankIntBase + 18: value = checked((int)item.Useability); return true;
|
||||
case VtankIntBase + 23: value = checked((int)item.PublicFlags); return true;
|
||||
case VtankIntBase + 31: value = item.CombatUse; return true;
|
||||
case VtankIntBase + 32: value = item.WeaponSkill; return true;
|
||||
case VtankIntBase + 33: value = item.DamageType; return true;
|
||||
case VtankIntBase + 34: value = item.Damage; return true;
|
||||
case VtankIntBase + 38: value = item.AppraisedSpellIds.Count; return true;
|
||||
default:
|
||||
value = 0;
|
||||
return properties.Ints?.TryGetValue(key, out value) == true;
|
||||
}
|
||||
}
|
||||
|
||||
private static int IntValue(
|
||||
uint key,
|
||||
in PluginInventoryItem item,
|
||||
in PluginItemProperties properties) => key switch
|
||||
in PluginItemProperties properties) =>
|
||||
TryIntValue(key, item, properties, out int value) ? value : 0;
|
||||
|
||||
/// <summary>See <see cref="TryDoubleValue"/> — the double-side equivalent of <see cref="TryIntValue"/>'s consolidation.</summary>
|
||||
private static bool TryDoubleValue(
|
||||
uint key,
|
||||
in PluginInventoryItem item,
|
||||
in PluginItemProperties properties,
|
||||
out double value)
|
||||
{
|
||||
5 => item.Burden,
|
||||
19 => item.Value,
|
||||
105 => checked((int)item.Workmanship),
|
||||
107 => item.ItemCurrentMana,
|
||||
108 => item.ItemMaximumMana,
|
||||
131 => checked((int)item.MaterialType),
|
||||
VtankIntBase + 0 => checked((int)item.WeenieClassId),
|
||||
VtankIntBase + 2 => checked((int)item.ContainerObjectId),
|
||||
VtankIntBase + 4 => item.ItemsCapacity,
|
||||
VtankIntBase + 5 => item.ContainersCapacity,
|
||||
VtankIntBase + 6 => item.StackSize,
|
||||
VtankIntBase + 7 => item.MaximumStackSize,
|
||||
VtankIntBase + 8 => checked((int)item.SpellId),
|
||||
VtankIntBase + 9 => item.ContainerSlot,
|
||||
VtankIntBase + 10 => checked((int)item.WielderObjectId),
|
||||
VtankIntBase + 11 => checked((int)item.EquippedLocation),
|
||||
VtankIntBase + 14 => checked((int)item.ValidLocations),
|
||||
VtankIntBase + 18 => checked((int)item.Useability),
|
||||
VtankIntBase + 23 => checked((int)item.PublicFlags),
|
||||
VtankIntBase + 31 => item.CombatUse,
|
||||
VtankIntBase + 32 => item.WeaponSkill,
|
||||
VtankIntBase + 33 => item.DamageType,
|
||||
VtankIntBase + 34 => item.Damage,
|
||||
VtankIntBase + 38 => item.AppraisedSpellIds.Count,
|
||||
_ => properties.Ints?.TryGetValue(key, out int value) == true
|
||||
? value
|
||||
: 0,
|
||||
};
|
||||
switch (key)
|
||||
{
|
||||
case VtankDoubleBase + 9: value = item.Workmanship; return true;
|
||||
case VtankDoubleBase + 11: value = item.DamageVariance; return true;
|
||||
// These two are named/virtual fields (like the int side's
|
||||
// VtankIntBase+N cases) that always "exist", regardless of
|
||||
// whether the underlying remapped raw key (62/63) happens to
|
||||
// be present in the property bag — matching the prior
|
||||
// DoubleKeyExists switch's explicit "=> true" for both.
|
||||
case VtankDoubleBase + 12:
|
||||
TryRawFloat(properties, 62, out value);
|
||||
return true;
|
||||
case VtankDoubleBase + 14:
|
||||
TryRawFloat(properties, 63, out value);
|
||||
return true;
|
||||
default:
|
||||
return TryRawFloat(properties, key, out value);
|
||||
}
|
||||
}
|
||||
|
||||
private static double DoubleValue(
|
||||
uint key,
|
||||
in PluginInventoryItem item,
|
||||
in PluginItemProperties properties) => key switch
|
||||
{
|
||||
VtankDoubleBase + 9 => item.Workmanship,
|
||||
VtankDoubleBase + 11 => item.DamageVariance,
|
||||
VtankDoubleBase + 12 => RawFloat(properties, 62),
|
||||
VtankDoubleBase + 14 => RawFloat(properties, 63),
|
||||
_ => RawFloat(properties, key),
|
||||
};
|
||||
in PluginItemProperties properties) =>
|
||||
TryDoubleValue(key, item, properties, out double value) ? value : 0d;
|
||||
|
||||
private static double RawFloat(in PluginItemProperties properties, uint key) =>
|
||||
properties.Floats?.TryGetValue(key, out double value) == true ? value : 0d;
|
||||
private static bool TryRawFloat(in PluginItemProperties properties, uint key, out double value)
|
||||
{
|
||||
value = 0d;
|
||||
return properties.Floats?.TryGetValue(key, out value) == true;
|
||||
}
|
||||
|
||||
private static int BuffedInt(
|
||||
uint key,
|
||||
|
|
@ -418,6 +480,15 @@ internal static class VtankLootRequirementEvaluator
|
|||
in PluginItemProperties properties)
|
||||
{
|
||||
int value = IntValue(key, item, properties);
|
||||
// VTClassic's ComputedItemInfo.GetBuffedLogValueKey only adds the
|
||||
// spell bonus if the item already carries the base key at all
|
||||
// (ComputedItemInfo.cs:205, the KeyExistsInt gate — KB doc 05
|
||||
// section 2.2 and the section-5 gap-4 finding). Without this gate,
|
||||
// an item that lacks the key entirely (falls through to the
|
||||
// zero-defaulted "_ =>" branch below) would still receive a spell
|
||||
// bonus it never had a base value to buff.
|
||||
if (!IntKeyExists(key, item, properties))
|
||||
return value;
|
||||
foreach (uint spellId in item.AppraisedSpellIds)
|
||||
{
|
||||
if (IntSpellBonuses.TryGetValue(spellId, out var bonus)
|
||||
|
|
@ -435,6 +506,8 @@ internal static class VtankLootRequirementEvaluator
|
|||
in PluginItemProperties properties)
|
||||
{
|
||||
double value = DoubleValue(key, item, properties);
|
||||
if (!DoubleKeyExists(key, item, properties))
|
||||
return value;
|
||||
foreach (uint spellId in item.AppraisedSpellIds)
|
||||
{
|
||||
if (!DoubleSpellBonuses.TryGetValue(spellId, out var bonus)
|
||||
|
|
@ -442,11 +515,28 @@ internal static class VtankLootRequirementEvaluator
|
|||
{
|
||||
continue;
|
||||
}
|
||||
value = (int)bonus.Bonus == 1 ? value * bonus.Bonus : value + bonus.Bonus;
|
||||
value = bonus.Change ? value * bonus.Bonus : value + bonus.Bonus;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Now driven by <see cref="TryIntValue"/> (item J, slice-1 fix round)
|
||||
/// instead of an independently hand-maintained duplicate of its key
|
||||
/// list — see that method's doc for why the duplication was a hazard.
|
||||
/// </summary>
|
||||
private static bool IntKeyExists(
|
||||
uint key,
|
||||
in PluginInventoryItem item,
|
||||
in PluginItemProperties properties) =>
|
||||
TryIntValue(key, item, properties, out _);
|
||||
|
||||
private static bool DoubleKeyExists(
|
||||
uint key,
|
||||
in PluginInventoryItem item,
|
||||
in PluginItemProperties properties) =>
|
||||
TryDoubleValue(key, item, properties, out _);
|
||||
|
||||
private static double MinimumDamage(in PluginInventoryItem item) =>
|
||||
item.Damage - (item.DamageVariance * item.Damage);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,17 @@
|
|||
using System.Globalization;
|
||||
using AcDream.Plugin.Abstractions;
|
||||
|
||||
namespace AcDream.Plugins.MossTank;
|
||||
|
||||
/// <summary>
|
||||
/// Reads and writes VTank's exact line-encoded <c>CondAct</c> Meta database.
|
||||
/// The format is the public interchange contract used by legacy <c>.met</c>
|
||||
/// profiles; it is deliberately independent from MossTank's native JSON store.
|
||||
/// Reads VTank's exact line-encoded <c>CondAct</c> Meta database — a
|
||||
/// one-shot import path only. Per the owner's 2026-09-06 "MossTank does not
|
||||
/// implement <c>.met</c>" direction (Campaign VT slice 1 Part A), the
|
||||
/// writer that used to live here was deleted: <c>.af</c>
|
||||
/// (<see cref="MetafSerializer"/>) is the only storage/authoring format for
|
||||
/// meta profiles now. This class only converts a legacy binary <c>.met</c>
|
||||
/// file into a <see cref="MetaProfile"/> so it can be saved straight back
|
||||
/// out as <c>.af</c>.
|
||||
/// </summary>
|
||||
internal static class VtankMetaProfileSerializer
|
||||
{
|
||||
|
|
@ -20,8 +26,16 @@ internal static class VtankMetaProfileSerializer
|
|||
private const int MaximumRules = 100_000;
|
||||
private const int MaximumNesting = 256;
|
||||
|
||||
public static bool TryLoad(string source, out MetaProfile profile, out string error)
|
||||
public static bool TryLoad(string source, out MetaProfile profile, out string error) =>
|
||||
TryLoad(source, MetafSerializer.NoOpSpells.Instance, out profile, out error);
|
||||
|
||||
public static bool TryLoad(
|
||||
string source,
|
||||
ISpellCatalog spells,
|
||||
out MetaProfile profile,
|
||||
out string error)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(spells);
|
||||
try
|
||||
{
|
||||
var reader = new LineReader(source);
|
||||
|
|
@ -38,7 +52,7 @@ internal static class VtankMetaProfileSerializer
|
|||
reader.Expect("i");
|
||||
int actionType = reader.ReadInt();
|
||||
MetaCondition condition = ReadCondition(reader, conditionType, 0);
|
||||
MetaAction action = ReadAction(reader, actionType, 0);
|
||||
MetaAction action = ReadAction(reader, actionType, 0, spells);
|
||||
reader.Expect("s");
|
||||
parsed.Rules.Add(new MetaRule
|
||||
{
|
||||
|
|
@ -62,23 +76,6 @@ internal static class VtankMetaProfileSerializer
|
|||
}
|
||||
}
|
||||
|
||||
public static string Save(MetaProfile source)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(source);
|
||||
var writer = new LineWriter();
|
||||
writer.Add(Header);
|
||||
MetaRule[] rules = source.Rules.Where(static rule => rule.Enabled).ToArray();
|
||||
writer.Add(rules.Length);
|
||||
foreach (MetaRule rule in rules)
|
||||
{
|
||||
writer.Add("i", ConditionType(rule.Condition.Kind), "i", ActionType(rule.Action.Kind));
|
||||
WriteCondition(writer, rule.Condition, 0);
|
||||
WriteAction(writer, rule.Action, 0);
|
||||
writer.Add("s", rule.State ?? string.Empty);
|
||||
}
|
||||
return writer.Finish();
|
||||
}
|
||||
|
||||
private static MetaCondition ReadCondition(LineReader reader, int type, int depth)
|
||||
{
|
||||
CheckDepth(reader, depth);
|
||||
|
|
@ -172,7 +169,7 @@ internal static class VtankMetaProfileSerializer
|
|||
}
|
||||
}
|
||||
|
||||
private static MetaAction ReadAction(LineReader reader, int type, int depth)
|
||||
private static MetaAction ReadAction(LineReader reader, int type, int depth, ISpellCatalog spells)
|
||||
{
|
||||
CheckDepth(reader, depth);
|
||||
var value = new MetaAction { Kind = ActionKind(type) };
|
||||
|
|
@ -191,11 +188,11 @@ internal static class VtankMetaProfileSerializer
|
|||
for (int index = 0; index < count; index++)
|
||||
{
|
||||
reader.Expect("i");
|
||||
value.Children.Add(ReadAction(reader, reader.ReadInt(), depth + 1));
|
||||
value.Children.Add(ReadAction(reader, reader.ReadInt(), depth + 1, spells));
|
||||
}
|
||||
break;
|
||||
case 4:
|
||||
ReadEmbeddedNavigation(reader, value);
|
||||
ReadEmbeddedNavigation(reader, value, spells);
|
||||
break;
|
||||
case 5:
|
||||
reader.Expect(TablePrefix, "2", "s", "st", "s");
|
||||
|
|
@ -247,7 +244,7 @@ internal static class VtankMetaProfileSerializer
|
|||
return value;
|
||||
}
|
||||
|
||||
private static void ReadEmbeddedNavigation(LineReader reader, MetaAction target)
|
||||
private static void ReadEmbeddedNavigation(LineReader reader, MetaAction target, ISpellCatalog spells)
|
||||
{
|
||||
reader.Expect("ba");
|
||||
int serializedCharacters = reader.ReadCount();
|
||||
|
|
@ -255,7 +252,7 @@ internal static class VtankMetaProfileSerializer
|
|||
int statedNodeCount = reader.ReadCount();
|
||||
if (serializedCharacters <= 5)
|
||||
{
|
||||
target.Text = EmptyNavigation();
|
||||
target.EmbeddedRoute = new NavigationSettings();
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -282,7 +279,15 @@ internal static class VtankMetaProfileSerializer
|
|||
}
|
||||
if (actualNodeCount != statedNodeCount)
|
||||
throw reader.Error("Embedded VTank navigation node counts do not match.");
|
||||
target.Text = string.Join("\r\n", lines) + "\r\n";
|
||||
|
||||
// Parse the reassembled "uTank2 NAV 1.2" text through the same
|
||||
// reader a top-level binary .nav import uses (item B: MetaAction
|
||||
// carries a typed NavigationSettings, not a re-parse-me blob).
|
||||
string blob = string.Join("\r\n", lines) + "\r\n";
|
||||
var route = new NavigationSettings();
|
||||
if (!VtankNavRouteSerializer.TryLoad(blob, route, spells, out string navError))
|
||||
throw reader.Error($"Embedded VTank navigation route is invalid: {navError}");
|
||||
target.EmbeddedRoute = route;
|
||||
}
|
||||
|
||||
private static void ReadNavigationNode(LineReader reader, List<string> lines)
|
||||
|
|
@ -304,195 +309,10 @@ internal static class VtankMetaProfileSerializer
|
|||
lines.Add(reader.Read());
|
||||
}
|
||||
|
||||
private static void WriteCondition(LineWriter writer, MetaCondition value, int depth)
|
||||
{
|
||||
CheckDepth(depth);
|
||||
int type = ConditionType(value.Kind);
|
||||
switch (type)
|
||||
{
|
||||
case 0 or 1 or 7 or 8 or 9 or 10 or 15 or 19 or 20:
|
||||
writer.Add("i", "0");
|
||||
break;
|
||||
case 2 or 3:
|
||||
writer.Add(RecursiveTablePrefix);
|
||||
writer.Add(value.Children.Count);
|
||||
foreach (MetaCondition child in value.Children)
|
||||
{
|
||||
writer.Add("i", ConditionType(child.Kind));
|
||||
WriteCondition(writer, child, depth + 1);
|
||||
}
|
||||
break;
|
||||
case 4:
|
||||
writer.Add("s", value.Text);
|
||||
break;
|
||||
case 5 or 6 or 17 or 18 or 22 or 24:
|
||||
writer.Add("i", IntValue(value.Number));
|
||||
break;
|
||||
case 11 or 12:
|
||||
writer.Add(TablePrefix, "2", "s", "n", "s", value.Text,
|
||||
"s", "c", "i", IntValue(value.Number));
|
||||
break;
|
||||
case 13:
|
||||
writer.Add(TablePrefix, "3", "s", "n", "s", value.Text,
|
||||
"s", "c", "i", IntValue(value.Number),
|
||||
"s", "r", "d", Number(value.SecondaryNumber));
|
||||
break;
|
||||
case 14:
|
||||
writer.Add(TablePrefix, "3", "s", "p", "i", IntValue(value.TertiaryNumber),
|
||||
"s", "c", "i", IntValue(value.Number),
|
||||
"s", "r", "d", Number(value.SecondaryNumber));
|
||||
break;
|
||||
case 16:
|
||||
writer.Add(TablePrefix, "1", "s", "r", "d", Number(value.Number));
|
||||
break;
|
||||
case 21:
|
||||
if (value.Children.Count != 1)
|
||||
throw new InvalidOperationException("VTank Meta Not requires exactly one condition.");
|
||||
writer.Add(RecursiveTablePrefix, "1", "i", ConditionType(value.Children[0].Kind));
|
||||
WriteCondition(writer, value.Children[0], depth + 1);
|
||||
break;
|
||||
case 23:
|
||||
writer.Add(TablePrefix, "2", "s", "sid", "i", IntValue(value.Number),
|
||||
"s", "sec", "i", IntValue(value.SecondaryNumber));
|
||||
break;
|
||||
case 25:
|
||||
writer.Add(TablePrefix, "1", "s", "dist", "d", Number(value.Number));
|
||||
break;
|
||||
case 26:
|
||||
writer.Add(TablePrefix, "1", "s", "e", "s", value.Text);
|
||||
break;
|
||||
case 28:
|
||||
writer.Add(TablePrefix, "2", "s", "p", "s", value.Text,
|
||||
"s", "c", "s", value.SecondaryText);
|
||||
break;
|
||||
default:
|
||||
throw new InvalidOperationException($"Unknown VTank Meta condition type {type}.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void WriteAction(LineWriter writer, MetaAction value, int depth)
|
||||
{
|
||||
CheckDepth(depth);
|
||||
int type = ActionType(value.Kind);
|
||||
switch (type)
|
||||
{
|
||||
case 0 or 6:
|
||||
writer.Add("i", "0");
|
||||
break;
|
||||
case 1 or 2:
|
||||
writer.Add("s", value.Text);
|
||||
break;
|
||||
case 3:
|
||||
writer.Add(RecursiveTablePrefix);
|
||||
writer.Add(value.Children.Count);
|
||||
foreach (MetaAction child in value.Children)
|
||||
{
|
||||
writer.Add("i", ActionType(child.Kind));
|
||||
WriteAction(writer, child, depth + 1);
|
||||
}
|
||||
break;
|
||||
case 4:
|
||||
WriteEmbeddedNavigation(writer, value);
|
||||
break;
|
||||
case 5:
|
||||
writer.Add(TablePrefix, "2", "s", "st", "s", value.Text,
|
||||
"s", "ret", "s", value.SecondaryText);
|
||||
break;
|
||||
case 7 or 8:
|
||||
writer.Add(TablePrefix, "1", "s", "e", "s", value.Text);
|
||||
break;
|
||||
case 9:
|
||||
writer.Add(TablePrefix, "3", "s", "s", "s", value.Text,
|
||||
"s", "r", "d", Number(value.Number),
|
||||
"s", "t", "d", Number(value.SecondaryNumber));
|
||||
break;
|
||||
case 10 or 15:
|
||||
writer.Add(TablePrefix, "0");
|
||||
break;
|
||||
case 11 or 12:
|
||||
writer.Add(TablePrefix, "2", "s", "o", "s", value.Text,
|
||||
"s", "v", "s", value.SecondaryText);
|
||||
break;
|
||||
case 13:
|
||||
writer.Add(TablePrefix, "2", "s", "n", "s", value.Text,
|
||||
"s", "x", "ba", value.SecondaryText.Length);
|
||||
writer.AddBuggedByteArray(value.SecondaryText);
|
||||
break;
|
||||
case 14:
|
||||
writer.Add(TablePrefix, "1", "s", "n", "s", value.Text);
|
||||
break;
|
||||
default:
|
||||
throw new InvalidOperationException($"Unknown VTank Meta action type {type}.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void WriteEmbeddedNavigation(LineWriter writer, MetaAction value)
|
||||
{
|
||||
string nav = string.IsNullOrWhiteSpace(value.Text) ? EmptyNavigation() : value.Text;
|
||||
string normalized = NormalizeNewlines(nav);
|
||||
string[] navLines = normalized.Split('\n', StringSplitOptions.None);
|
||||
if (navLines.Length != 0 && navLines[^1].Length == 0)
|
||||
navLines = navLines[..^1];
|
||||
int nodes = NavigationNodeCount(navLines);
|
||||
string name = string.IsNullOrEmpty(value.SecondaryText) ? "[None]" : value.SecondaryText;
|
||||
int characters = name.Length + 2
|
||||
+ nodes.ToString(CultureInfo.InvariantCulture).Length + 2
|
||||
+ navLines.Sum(static line => line.Length + 2);
|
||||
writer.Add("ba", characters, name, nodes);
|
||||
writer.Add(navLines);
|
||||
}
|
||||
|
||||
private static int NavigationNodeCount(string[] lines)
|
||||
{
|
||||
if (lines.Length < 2 || !lines[0].Equals("uTank2 NAV 1.2", StringComparison.Ordinal))
|
||||
throw new InvalidOperationException("Embedded Meta route is not uTank2 NAV 1.2 data.");
|
||||
int mode = int.Parse(lines[1], NumberStyles.Integer, CultureInfo.InvariantCulture);
|
||||
if (mode == 3)
|
||||
return 1;
|
||||
if (mode is not (1 or 2 or 4) || lines.Length < 3)
|
||||
throw new InvalidOperationException("Embedded Meta route has an invalid navigation type.");
|
||||
return int.Parse(lines[2], NumberStyles.Integer, CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
private static string EmptyNavigation() => "uTank2 NAV 1.2\r\n1\r\n0\r\n";
|
||||
|
||||
private static string NormalizeNewlines(string value) => value
|
||||
.Replace("\r\n", "\n", StringComparison.Ordinal)
|
||||
.Replace('\r', '\n');
|
||||
|
||||
private static int ConditionType(MetaConditionKind kind) => kind switch
|
||||
{
|
||||
MetaConditionKind.Never => 0,
|
||||
MetaConditionKind.Always => 1,
|
||||
MetaConditionKind.All => 2,
|
||||
MetaConditionKind.Any => 3,
|
||||
MetaConditionKind.ChatMessage => 4,
|
||||
MetaConditionKind.PackSlotsLessThanOrEqual => 5,
|
||||
MetaConditionKind.SecondsInStateGreaterThanOrEqual => 6,
|
||||
MetaConditionKind.NavigationRouteEmpty => 7,
|
||||
MetaConditionKind.CharacterDeath => 8,
|
||||
MetaConditionKind.AnyVendorOpen => 9,
|
||||
MetaConditionKind.VendorClosed => 10,
|
||||
MetaConditionKind.InventoryItemCountLessThanOrEqual => 11,
|
||||
MetaConditionKind.InventoryItemCountGreaterThanOrEqual => 12,
|
||||
MetaConditionKind.MonsterNameCountWithinDistance => 13,
|
||||
MetaConditionKind.MonsterPriorityCountWithinDistance => 14,
|
||||
MetaConditionKind.NeedToBuff => 15,
|
||||
MetaConditionKind.NoMonstersWithinDistance => 16,
|
||||
MetaConditionKind.LandblockEquals => 17,
|
||||
MetaConditionKind.LandcellEquals => 18,
|
||||
MetaConditionKind.PortalspaceEntered => 19,
|
||||
MetaConditionKind.PortalspaceExited => 20,
|
||||
MetaConditionKind.Not => 21,
|
||||
MetaConditionKind.PersistentSecondsInStateGreaterThanOrEqual => 22,
|
||||
MetaConditionKind.TimeLeftOnSpellGreaterThanOrEqual => 23,
|
||||
MetaConditionKind.BurdenPercentGreaterThanOrEqual => 24,
|
||||
MetaConditionKind.DistanceFromAnyRoutePointGreaterThanOrEqual => 25,
|
||||
MetaConditionKind.Expression => 26,
|
||||
MetaConditionKind.ChatMessageCapture => 28,
|
||||
_ => throw new InvalidOperationException($"Unsupported Meta condition {kind}."),
|
||||
};
|
||||
|
||||
private static MetaConditionKind ConditionKind(int type) => type switch
|
||||
{
|
||||
0 => MetaConditionKind.Never,
|
||||
|
|
@ -526,27 +346,6 @@ internal static class VtankMetaProfileSerializer
|
|||
_ => throw new FormatException($"Unknown VTank Meta condition type {type}."),
|
||||
};
|
||||
|
||||
private static int ActionType(MetaActionKind kind) => kind switch
|
||||
{
|
||||
MetaActionKind.None => 0,
|
||||
MetaActionKind.SetMetaState => 1,
|
||||
MetaActionKind.ChatCommand => 2,
|
||||
MetaActionKind.All => 3,
|
||||
MetaActionKind.LoadEmbeddedNavigationRoute => 4,
|
||||
MetaActionKind.CallMetaState => 5,
|
||||
MetaActionKind.ReturnFromCall => 6,
|
||||
MetaActionKind.ExpressionAction => 7,
|
||||
MetaActionKind.ChatExpression => 8,
|
||||
MetaActionKind.SetWatchdog => 9,
|
||||
MetaActionKind.ClearWatchdog => 10,
|
||||
MetaActionKind.GetVtankOption => 11,
|
||||
MetaActionKind.SetVtankOption => 12,
|
||||
MetaActionKind.CreateView => 13,
|
||||
MetaActionKind.DestroyView => 14,
|
||||
MetaActionKind.DestroyAllViews => 15,
|
||||
_ => throw new InvalidOperationException($"Unsupported Meta action {kind}."),
|
||||
};
|
||||
|
||||
private static MetaActionKind ActionKind(int type) => type switch
|
||||
{
|
||||
0 => MetaActionKind.None,
|
||||
|
|
@ -568,32 +367,12 @@ internal static class VtankMetaProfileSerializer
|
|||
_ => throw new FormatException($"Unknown VTank Meta action type {type}."),
|
||||
};
|
||||
|
||||
private static string Number(double value)
|
||||
{
|
||||
if (!double.IsFinite(value))
|
||||
throw new InvalidOperationException("VTank Meta numbers must be finite.");
|
||||
return value.ToString("R", CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
private static int IntValue(double value)
|
||||
{
|
||||
if (!double.IsFinite(value) || value != Math.Truncate(value))
|
||||
throw new InvalidOperationException("VTank Meta integer fields require whole numbers.");
|
||||
return checked((int)value);
|
||||
}
|
||||
|
||||
private static void CheckDepth(LineReader reader, int depth)
|
||||
{
|
||||
if (depth > MaximumNesting)
|
||||
throw reader.Error("VTank Meta nesting is too deep.");
|
||||
}
|
||||
|
||||
private static void CheckDepth(int depth)
|
||||
{
|
||||
if (depth > MaximumNesting)
|
||||
throw new InvalidOperationException("VTank Meta nesting is too deep.");
|
||||
}
|
||||
|
||||
private sealed class LineReader
|
||||
{
|
||||
private readonly List<string> _lines;
|
||||
|
|
@ -703,40 +482,4 @@ internal static class VtankMetaProfileSerializer
|
|||
public FormatException Error(string message) =>
|
||||
new($"VTank Meta line {Math.Min(_index + 1, _lines.Count + 1)}: {message}");
|
||||
}
|
||||
|
||||
private sealed class LineWriter
|
||||
{
|
||||
private readonly List<string> _lines = [];
|
||||
private readonly List<int> _buggedByteArrays = [];
|
||||
|
||||
public void Add(params object?[] values)
|
||||
{
|
||||
foreach (object? value in values)
|
||||
_lines.Add(Convert.ToString(value, CultureInfo.InvariantCulture) ?? string.Empty);
|
||||
}
|
||||
|
||||
public void Add(string[] first, params object?[] rest)
|
||||
{
|
||||
Add(first.Cast<object?>().ToArray());
|
||||
Add(rest);
|
||||
}
|
||||
|
||||
public void AddBuggedByteArray(string value)
|
||||
{
|
||||
_buggedByteArrays.Add(_lines.Count);
|
||||
_lines.Add(value ?? string.Empty);
|
||||
}
|
||||
|
||||
public string Finish()
|
||||
{
|
||||
foreach (int index in _buggedByteArrays.OrderDescending())
|
||||
{
|
||||
if (index + 1 >= _lines.Count)
|
||||
throw new InvalidOperationException("CreateView cannot terminate a VTank Meta record.");
|
||||
_lines[index] += _lines[index + 1];
|
||||
_lines.RemoveAt(index + 1);
|
||||
}
|
||||
return string.Join("\r\n", _lines) + "\r\n";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,40 +3,27 @@ using AcDream.Plugin.Abstractions;
|
|||
|
||||
namespace AcDream.Plugins.MossTank;
|
||||
|
||||
/// <summary>Reader for VTank's verbatim <c>uTank2 NAV 1.2</c> format.</summary>
|
||||
/// <summary>
|
||||
/// Reader for VTank's verbatim <c>uTank2 NAV 1.2</c> format — a one-shot
|
||||
/// import path only. Per the owner's 2026-09-06 "MossTank does not author
|
||||
/// <c>.nav</c>" direction (Campaign VT slice 1 Part A), the writer that
|
||||
/// used to live here was deleted: <c>.af</c> (<see cref="MetafSerializer"/>)
|
||||
/// is the only storage/authoring format for navigation routes now.
|
||||
/// <see cref="MetaEngine"/>'s embedded-navigation contract (a Meta rule's
|
||||
/// <see cref="MetaActionKind.LoadEmbeddedNavigationRoute"/> action,
|
||||
/// <c>.af</c> tag <c>EmbedNav</c>) is a typed
|
||||
/// <see cref="MetaAction.EmbeddedRoute"/> <see cref="NavigationSettings"/>
|
||||
/// now (round 2 step B, replacing an earlier binary-blob shape), saved and
|
||||
/// loaded through the SAME <see cref="MetafSerializer.SaveNav"/>/
|
||||
/// <see cref="MetafSerializer.TryLoadNav"/> grammar this class no longer
|
||||
/// owns any writer for — round 3 item 12 cleanup: this doc comment
|
||||
/// previously cited a "<c>WriteBinaryNavBlob</c>" method that does not
|
||||
/// exist anywhere in the codebase.
|
||||
/// </summary>
|
||||
internal static class VtankNavRouteSerializer
|
||||
{
|
||||
private const string Header = "uTank2 NAV 1.2";
|
||||
|
||||
public static string Save(NavigationSettings source)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(source);
|
||||
var writer = new StringWriter(CultureInfo.InvariantCulture)
|
||||
{
|
||||
NewLine = "\r\n",
|
||||
};
|
||||
writer.WriteLine(Header);
|
||||
writer.WriteLine(source.Mode switch
|
||||
{
|
||||
RouteMode.Circular => 1,
|
||||
RouteMode.Linear => 2,
|
||||
RouteMode.Target => 3,
|
||||
RouteMode.Once => 4,
|
||||
_ => throw new InvalidOperationException("Unknown navigation type."),
|
||||
});
|
||||
if (source.Mode == RouteMode.Target)
|
||||
{
|
||||
writer.WriteLine(source.FollowTargetName ?? string.Empty);
|
||||
writer.WriteLine(unchecked((int)source.FollowTargetObjectId));
|
||||
return writer.ToString();
|
||||
}
|
||||
|
||||
writer.WriteLine(source.Waypoints.Count);
|
||||
foreach (RouteWaypoint waypoint in source.Waypoints)
|
||||
WriteWaypoint(writer, waypoint);
|
||||
return writer.ToString();
|
||||
}
|
||||
|
||||
public static bool TryLoad(
|
||||
string source,
|
||||
NavigationSettings target,
|
||||
|
|
@ -145,13 +132,23 @@ internal static class VtankNavRouteSerializer
|
|||
break;
|
||||
case 6:
|
||||
case 7:
|
||||
// The record's leading eastWest/northSouth/elevation (read
|
||||
// above into waypoint.Position) is "myxyz" — where the
|
||||
// character stood when the waypoint was authored. This
|
||||
// trailing triple is the portal/NPC object's own recorded
|
||||
// position ("tgtxyz" in metaf's parallel ptl/tlk grammar —
|
||||
// see MetafSerializer.ReadNavNode), kept separately so a
|
||||
// reload can still disambiguate which object at that name
|
||||
// to interact with, matching the record shape exactly:
|
||||
// the pre-existing single-Position port used to overwrite
|
||||
// "myxyz" with this value instead of keeping both.
|
||||
waypoint.ObjectName = ReadLine(reader);
|
||||
waypoint.LegacyObjectClass = ReadInt(reader);
|
||||
waypoint.LegacyReferenceValid = ReadBoolean(reader);
|
||||
double referenceEastWest = ReadDouble(reader);
|
||||
double referenceNorthSouth = ReadDouble(reader);
|
||||
double referenceElevation = ReadDouble(reader);
|
||||
waypoint.Position = Position(
|
||||
waypoint.ReferencePosition = Position(
|
||||
referenceEastWest,
|
||||
referenceNorthSouth,
|
||||
referenceElevation);
|
||||
|
|
@ -165,76 +162,6 @@ internal static class VtankNavRouteSerializer
|
|||
return waypoint;
|
||||
}
|
||||
|
||||
private static void WriteWaypoint(TextWriter writer, RouteWaypoint waypoint)
|
||||
{
|
||||
int type = (int)waypoint.Type;
|
||||
writer.WriteLine(type.ToString(CultureInfo.InvariantCulture));
|
||||
WriteDouble(writer, waypoint.Position.EastWest);
|
||||
WriteDouble(writer, waypoint.Position.NorthSouth);
|
||||
WriteDouble(writer, waypoint.Position.Elevation);
|
||||
writer.WriteLine("0");
|
||||
switch (waypoint.Type)
|
||||
{
|
||||
case RouteWaypointType.Point:
|
||||
case RouteWaypointType.Checkpoint:
|
||||
break;
|
||||
case RouteWaypointType.Portal:
|
||||
writer.WriteLine(unchecked((int)waypoint.ObjectId)
|
||||
.ToString(CultureInfo.InvariantCulture));
|
||||
break;
|
||||
case RouteWaypointType.Recall:
|
||||
writer.WriteLine(waypoint.RecallSpellId
|
||||
.ToString(CultureInfo.InvariantCulture));
|
||||
break;
|
||||
case RouteWaypointType.Pause:
|
||||
writer.WriteLine(waypoint.DurationMilliseconds
|
||||
.ToString(CultureInfo.InvariantCulture));
|
||||
break;
|
||||
case RouteWaypointType.ChatCommand:
|
||||
writer.WriteLine(waypoint.Text ?? string.Empty);
|
||||
break;
|
||||
case RouteWaypointType.OpenVendor:
|
||||
writer.WriteLine(unchecked((int)waypoint.ObjectId)
|
||||
.ToString(CultureInfo.InvariantCulture));
|
||||
writer.WriteLine(waypoint.ObjectName ?? string.Empty);
|
||||
break;
|
||||
case RouteWaypointType.PortalByName:
|
||||
case RouteWaypointType.UseNpc:
|
||||
writer.WriteLine(waypoint.ObjectName ?? string.Empty);
|
||||
int objectClass = waypoint.LegacyObjectClass != 0
|
||||
? waypoint.LegacyObjectClass
|
||||
: waypoint.Type == RouteWaypointType.PortalByName ? 14 : 37;
|
||||
writer.WriteLine(objectClass.ToString(CultureInfo.InvariantCulture));
|
||||
writer.WriteLine(waypoint.LegacyReferenceValid
|
||||
.ToString(CultureInfo.InvariantCulture));
|
||||
WriteDouble(writer, waypoint.Position.EastWest);
|
||||
WriteDouble(writer, waypoint.Position.NorthSouth);
|
||||
WriteDouble(writer, waypoint.Position.Elevation);
|
||||
break;
|
||||
case RouteWaypointType.Jump:
|
||||
WriteDouble(writer, waypoint.JumpHeadingDegrees);
|
||||
writer.WriteLine(waypoint.JumpRun.ToString(CultureInfo.InvariantCulture));
|
||||
string suffix = waypoint.JumpDirection switch
|
||||
{
|
||||
RouteJumpDirection.StrafeLeft => "4",
|
||||
RouteJumpDirection.StrafeRight => "5",
|
||||
_ => "3",
|
||||
};
|
||||
writer.WriteLine(
|
||||
waypoint.JumpChargeMilliseconds.ToString(
|
||||
"0.0000",
|
||||
CultureInfo.InvariantCulture)
|
||||
+ suffix);
|
||||
break;
|
||||
default:
|
||||
throw new InvalidOperationException(
|
||||
$"Unknown waypoint type {waypoint.Type}.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void WriteDouble(TextWriter writer, double value) =>
|
||||
writer.WriteLine(Convert.ToString(value, CultureInfo.InvariantCulture));
|
||||
|
||||
private static void ParseJump(string source, RouteWaypoint target)
|
||||
{
|
||||
string value = source.Trim();
|
||||
|
|
@ -277,7 +204,14 @@ internal static class VtankNavRouteSerializer
|
|||
0f,
|
||||
IsOutdoor: true);
|
||||
|
||||
private static void Apply(NavigationSettings source, NavigationSettings target)
|
||||
/// <summary>
|
||||
/// Copies every field <see cref="NavigationSettings"/> owns from
|
||||
/// <paramref name="source"/> into the live <paramref name="target"/>
|
||||
/// instance (internal, not private, so <c>MetaEngine</c>'s embedded-
|
||||
/// route consumer can reuse the exact same copy the top-level import
|
||||
/// path uses instead of re-parsing already-typed data).
|
||||
/// </summary>
|
||||
internal static void Apply(NavigationSettings source, NavigationSettings target)
|
||||
{
|
||||
target.Mode = source.Mode;
|
||||
target.FollowTargetObjectId = source.FollowTargetObjectId;
|
||||
|
|
|
|||
|
|
@ -5,6 +5,26 @@ namespace AcDream.Plugins.MossTank;
|
|||
/// <c>uTank2.Resources.defaultsettings.usd</c>. Order is retained because
|
||||
/// <c>/vt opt list</c> presents the database order four entries per line.
|
||||
/// </summary>
|
||||
/// <summary>
|
||||
/// VTank's <c>uTank2.eSettingValueType</c> enum
|
||||
/// (<c>refs/vtank/decompiled/uTank2/eSettingValueType.cs</c>) — the value
|
||||
/// each Settings row's own <c>SettingType</c> cell declares. This is the
|
||||
/// authority for which <see cref="VtankCell"/> type tag a Settings row's
|
||||
/// Value cell must use; it is independent of the CLR type of the C# field
|
||||
/// the live setting is stored in (e.g. every Recharge-* percentage is a
|
||||
/// <c>tInt</c> row even though the live field is a <c>double</c> fraction).
|
||||
/// </summary>
|
||||
internal enum VtankSettingValueType
|
||||
{
|
||||
Bool = 1,
|
||||
Double = 2,
|
||||
Int = 3,
|
||||
Single = 4,
|
||||
String = 5,
|
||||
Enum = 6,
|
||||
Custom = 7,
|
||||
}
|
||||
|
||||
internal static class VtankOptionCatalog
|
||||
{
|
||||
internal static readonly string[] Names =
|
||||
|
|
@ -208,6 +228,151 @@ internal static class VtankOptionCatalog
|
|||
["RechargeHandlerSet"] = MonsterValue.FromText("RechargeHandlerSet"),
|
||||
};
|
||||
|
||||
// Exact per-row SettingType from the shipped defaultsettings.usd
|
||||
// Settings table (verified 2026-09-06 against the committed
|
||||
// Fixtures/vtank/defaultsettings.usd, all 137 rows).
|
||||
private static readonly IReadOnlyDictionary<string, VtankSettingValueType> DeclaredTypes =
|
||||
new Dictionary<string, VtankSettingValueType>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["EnableLooting"] = VtankSettingValueType.Bool,
|
||||
["EnableNav"] = VtankSettingValueType.Bool,
|
||||
["EnableBuffing"] = VtankSettingValueType.Bool,
|
||||
["EnableCombat"] = VtankSettingValueType.Bool,
|
||||
["SpellDiffExcessThreshold-Hunt"] = VtankSettingValueType.Int,
|
||||
["SpellDiffExcessThreshold-Buff"] = VtankSettingValueType.Int,
|
||||
["ArrowheadFletchDiffExcessThreshold"] = VtankSettingValueType.Int,
|
||||
["Recharge-Norm-HitP"] = VtankSettingValueType.Int,
|
||||
["Recharge-Norm-Stam"] = VtankSettingValueType.Int,
|
||||
["Recharge-Norm-Mana"] = VtankSettingValueType.Int,
|
||||
["Recharge-NoTarg-HitP"] = VtankSettingValueType.Int,
|
||||
["Recharge-NoTarg-Stam"] = VtankSettingValueType.Int,
|
||||
["Recharge-NoTarg-Mana"] = VtankSettingValueType.Int,
|
||||
["Recharge-Helper-HitP"] = VtankSettingValueType.Int,
|
||||
["Recharge-Helper-Stam"] = VtankSettingValueType.Int,
|
||||
["Recharge-Helper-Mana"] = VtankSettingValueType.Int,
|
||||
["DoHelp"] = VtankSettingValueType.Bool,
|
||||
["AttackDistance"] = VtankSettingValueType.Double,
|
||||
["AttackMinimumDistance"] = VtankSettingValueType.Double,
|
||||
["ApproachDistance"] = VtankSettingValueType.Double,
|
||||
["RingDistance"] = VtankSettingValueType.Double,
|
||||
["CorpseApproachRange-Max"] = VtankSettingValueType.Double,
|
||||
["CorpseApproachRange-Min"] = VtankSettingValueType.Double,
|
||||
["NavCloseStopRange"] = VtankSettingValueType.Double,
|
||||
["NavFarStopRange"] = VtankSettingValueType.Double,
|
||||
["UsePortalDistance"] = VtankSettingValueType.Double,
|
||||
["HelperDistanceHitP"] = VtankSettingValueType.Double,
|
||||
["HelperDistanceStam"] = VtankSettingValueType.Double,
|
||||
["HelperDistanceMana"] = VtankSettingValueType.Double,
|
||||
["MinimumRingTargets"] = VtankSettingValueType.Int,
|
||||
["DefaultMeleeAttackHeight"] = VtankSettingValueType.Int,
|
||||
["CastDispelSelf"] = VtankSettingValueType.Bool,
|
||||
["UseDispelItems"] = VtankSettingValueType.Bool,
|
||||
["AutoCram"] = VtankSettingValueType.Bool,
|
||||
["AutoStack"] = VtankSettingValueType.Bool,
|
||||
["ReadUnknownScrolls"] = VtankSettingValueType.Bool,
|
||||
["UseDispelDrum"] = VtankSettingValueType.Bool,
|
||||
["SwitchWandsToDebuff"] = VtankSettingValueType.Bool,
|
||||
["AutoCraftItems"] = VtankSettingValueType.Bool,
|
||||
["UseHealersHeart"] = VtankSettingValueType.Bool,
|
||||
["JumpOutWandCasting"] = VtankSettingValueType.Bool,
|
||||
["LootAllCorpses"] = VtankSettingValueType.Bool,
|
||||
["LootFellowCorpses"] = VtankSettingValueType.Bool,
|
||||
["DoJiggle"] = VtankSettingValueType.Bool,
|
||||
["RandomHelperBuffs"] = VtankSettingValueType.Bool,
|
||||
["RandomHelperIntervalSeconds"] = VtankSettingValueType.Double,
|
||||
["IdlePeaceMode"] = VtankSettingValueType.Bool,
|
||||
["TargetLock"] = VtankSettingValueType.Bool,
|
||||
["StopMacroOnDeath"] = VtankSettingValueType.Bool,
|
||||
["UseArcs"] = VtankSettingValueType.Enum,
|
||||
["ArcRange"] = VtankSettingValueType.Double,
|
||||
["TargetSelectMethod"] = VtankSettingValueType.Enum,
|
||||
["TargetSelectAngleRange"] = VtankSettingValueType.Double,
|
||||
["IdleBuffTopoff"] = VtankSettingValueType.Bool,
|
||||
["IdleBuffTopoffTimeSeconds"] = VtankSettingValueType.Int,
|
||||
["RebuffTimeRemainingSeconds"] = VtankSettingValueType.Int,
|
||||
["RefillWornMana"] = VtankSettingValueType.Bool,
|
||||
["RefillWornMana-Item-ManaPercent"] = VtankSettingValueType.Int,
|
||||
["BuffProfile-Prots"] = VtankSettingValueType.String,
|
||||
["BuffProfile-Banes"] = VtankSettingValueType.String,
|
||||
["BuffProfile_Prots"] = VtankSettingValueType.Enum,
|
||||
["BuffProfile_Banes"] = VtankSettingValueType.Enum,
|
||||
["DebuffEachFirst"] = VtankSettingValueType.Enum,
|
||||
["AutoAttackPower"] = VtankSettingValueType.Bool,
|
||||
["LootPriorityBoost"] = VtankSettingValueType.Bool,
|
||||
["CorpseCacheTimeoutMinutes"] = VtankSettingValueType.Double,
|
||||
["CorpseItemAppearanceTimeoutSeconds"] = VtankSettingValueType.Double,
|
||||
["CorpseItemIDTimeoutSeconds"] = VtankSettingValueType.Double,
|
||||
["DebuffSelectionMethod"] = VtankSettingValueType.Enum,
|
||||
["ManaStoneLootCount"] = VtankSettingValueType.Int,
|
||||
["ManaTankMinimumMana"] = VtankSettingValueType.Int,
|
||||
["SplitPeas"] = VtankSettingValueType.Bool,
|
||||
["SpellCompMin-Critical"] = VtankSettingValueType.Int,
|
||||
["SpellCompMin-Normal"] = VtankSettingValueType.Int,
|
||||
["SpellCompMin-Idle"] = VtankSettingValueType.Int,
|
||||
["RechargeBoostTimeSeconds"] = VtankSettingValueType.Double,
|
||||
["RechargeBoostAmount"] = VtankSettingValueType.Int,
|
||||
["UseSpecialAmmo"] = VtankSettingValueType.Enum,
|
||||
["OpenDoors"] = VtankSettingValueType.Bool,
|
||||
["DoorIDRange"] = VtankSettingValueType.Double,
|
||||
["DoorOpenRange"] = VtankSettingValueType.Double,
|
||||
["DoorLockpickDiffExcessThreshold"] = VtankSettingValueType.Int,
|
||||
["ManaChargesWhenOff"] = VtankSettingValueType.Bool,
|
||||
["AutoFellowManagement"] = VtankSettingValueType.Bool,
|
||||
["MinimumHealKitSuccessChance"] = VtankSettingValueType.Int,
|
||||
["UseKitsInMagicMode"] = VtankSettingValueType.Bool,
|
||||
["StaminaToHealthMultiplier"] = VtankSettingValueType.Double,
|
||||
["ManaToHealthMultiplier"] = VtankSettingValueType.Double,
|
||||
["NavPriorityBoost"] = VtankSettingValueType.Bool,
|
||||
["DeleteGhostMonsters"] = VtankSettingValueType.Bool,
|
||||
["GhostMonsterSpellAttemptCount"] = VtankSettingValueType.Int,
|
||||
["WhoYouGonnaCall"] = VtankSettingValueType.Bool,
|
||||
["BlacklistMonsterAttemptCount"] = VtankSettingValueType.Int,
|
||||
["BlacklistMonsterTimeoutSeconds"] = VtankSettingValueType.Int,
|
||||
["CombineSalvage"] = VtankSettingValueType.Bool,
|
||||
["LootOnlyRareCorpses"] = VtankSettingValueType.Bool,
|
||||
["DeleteGhostMonstersByHPTracker"] = VtankSettingValueType.Bool,
|
||||
["GhostDeleteHPTrackerSeconds"] = VtankSettingValueType.Int,
|
||||
["GoToPeaceModeToUseKits"] = VtankSettingValueType.Bool,
|
||||
["UseRecklessness"] = VtankSettingValueType.Bool,
|
||||
["DebuffPrecastSeconds"] = VtankSettingValueType.Int,
|
||||
["ClearLevelBoostFlagOnCast"] = VtankSettingValueType.Bool,
|
||||
["IdleCraftCount_HealthKits"] = VtankSettingValueType.Int,
|
||||
["IdleCraftCount_StamKits"] = VtankSettingValueType.Int,
|
||||
["IdleCraftCount_ManaKits"] = VtankSettingValueType.Int,
|
||||
["IdleCraftCount_HealthFood"] = VtankSettingValueType.Int,
|
||||
["IdleCraftCount_StamFood"] = VtankSettingValueType.Int,
|
||||
["IdleCraftCount_ManaFood"] = VtankSettingValueType.Int,
|
||||
["BuffCastRecast_Seconds"] = VtankSettingValueType.Int,
|
||||
["BuffCastRecastReset_Seconds"] = VtankSettingValueType.Int,
|
||||
["EnableMeta"] = VtankSettingValueType.Bool,
|
||||
["BlacklistedSpellComps"] = VtankSettingValueType.String,
|
||||
["DropToPeaceModeRetryCount"] = VtankSettingValueType.Int,
|
||||
["FollowAroundCorners"] = VtankSettingValueType.Bool,
|
||||
["BlacklistCorpseOpenAttemptCount"] = VtankSettingValueType.Int,
|
||||
["BlacklistCorpseOpenTimeoutSeconds"] = VtankSettingValueType.Int,
|
||||
["SummonPets"] = VtankSettingValueType.Bool,
|
||||
["PetRangeMode"] = VtankSettingValueType.Enum,
|
||||
["PetCustomRange"] = VtankSettingValueType.Double,
|
||||
["PetRefillCount-Idle"] = VtankSettingValueType.Int,
|
||||
["PetRefillCount-Normal"] = VtankSettingValueType.Int,
|
||||
["CorpseOpenTimeoutSeconds"] = VtankSettingValueType.Double,
|
||||
["PetMonsterDensity"] = VtankSettingValueType.Int,
|
||||
["CorpseLootItemMaxAttempts"] = VtankSettingValueType.Int,
|
||||
["FastCastBuffs"] = VtankSettingValueType.Bool,
|
||||
["UseBreakableTurnTo"] = VtankSettingValueType.Bool,
|
||||
["UseProjectileAwareness"] = VtankSettingValueType.Bool,
|
||||
["CollisionProjectileRadius"] = VtankSettingValueType.Double,
|
||||
["CollisionStepDistance"] = VtankSettingValueType.Double,
|
||||
["ShowCollisionDebug"] = VtankSettingValueType.Bool,
|
||||
["MaximumCollisionChecksPerTick"] = VtankSettingValueType.Int,
|
||||
["SpellRangeFudge"] = VtankSettingValueType.Double,
|
||||
["BuffWithUntrained-Item"] = VtankSettingValueType.Int,
|
||||
["BuffWithUntrained-Creature"] = VtankSettingValueType.Int,
|
||||
["BuffWithUntrained-Life"] = VtankSettingValueType.Int,
|
||||
["AllowDebuffFallback"] = VtankSettingValueType.Bool,
|
||||
["RechargeHandlerSet"] = VtankSettingValueType.Custom,
|
||||
};
|
||||
|
||||
internal static bool IsKnown(string name) =>
|
||||
Names.Contains(name, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
|
|
@ -218,4 +383,10 @@ internal static class VtankOptionCatalog
|
|||
Defaults.TryGetValue(name, out MonsterValue value)
|
||||
? value
|
||||
: MonsterValue.FromNumber(0d);
|
||||
|
||||
/// <summary>The <c>eSettingValueType</c> VTank's own Settings row declares for this name.</summary>
|
||||
internal static VtankSettingValueType DeclaredType(string name) =>
|
||||
DeclaredTypes.TryGetValue(name, out VtankSettingValueType type)
|
||||
? type
|
||||
: VtankSettingValueType.Double;
|
||||
}
|
||||
|
|
|
|||
404
src/AcDream.Plugins.MossTank/VtankProfileDirectory.cs
Normal file
404
src/AcDream.Plugins.MossTank/VtankProfileDirectory.cs
Normal file
|
|
@ -0,0 +1,404 @@
|
|||
using AcDream.Plugin.Abstractions;
|
||||
|
||||
namespace AcDream.Plugins.MossTank;
|
||||
|
||||
/// <summary>
|
||||
/// Implements VTank's real profile-directory naming/selection rules
|
||||
/// (<c>docs/research/vtank-kb/01-settings-and-profiles.md</c> section 3):
|
||||
/// the per-character auto file, the longer <c>--Name_Server_</c>
|
||||
/// sub-profile prefix, and which filenames a given character can see.
|
||||
///
|
||||
/// The directory itself is never resolved here: every listing method reads
|
||||
/// through <see cref="IPluginHost.VtankProfiles"/> (an <see cref="IPluginStorage"/>
|
||||
/// the App/Headless host composes — real installed VTank's own profile
|
||||
/// folder for direct interop, or a portable per-OS default under
|
||||
/// <c>ApplicationPathSet.DataDirectory</c>). This class has no
|
||||
/// <c>System.IO</c> dependency and no portable-default fallback of its
|
||||
/// own: when the host has no opinion, <see cref="IPluginHost.VtankProfiles"/>
|
||||
/// defaults to the inert <c>NoOpPluginStorage</c> (<c>IsAvailable</c>
|
||||
/// false), and every listing here degrades to just its built-in entries,
|
||||
/// exactly like a missing directory used to.
|
||||
/// </summary>
|
||||
internal static class VtankProfileDirectory
|
||||
{
|
||||
/// <summary>The real VTank "--" reserved-prefix marker (section 3).</summary>
|
||||
internal const string HiddenPrefix = "--";
|
||||
|
||||
/// <summary>
|
||||
/// The nav-profile-only "~~" reserved prefix (section 3) — VTank's own
|
||||
/// producer/purpose was not determined by the KB research pass either;
|
||||
/// this only reproduces the filter.
|
||||
/// </summary>
|
||||
internal const string NavHiddenPrefix = "~~";
|
||||
|
||||
internal const string ByCharacterLabel = "[By char]";
|
||||
internal const string DefaultLabel = "[Default]";
|
||||
internal const string NoneLabel = "[None]";
|
||||
|
||||
/// <summary>
|
||||
/// The <c>nav_</c> marker (round 3 items 3/4) that distinguishes a
|
||||
/// stand-alone route <c>.af</c> from a Meta profile sharing the same
|
||||
/// flat <see cref="IPluginHost.VtankProfiles"/> directory and extension
|
||||
/// — metaf's own observed convention for a nav-only <c>.af</c> (see the
|
||||
/// committed <c>nav_*.af</c> fixtures). MUST be checked AFTER stripping
|
||||
/// <see cref="HiddenPrefix"/> when the marker is combined with it (see
|
||||
/// <see cref="AutoCharacterFileName(string,string,string,string)"/>'s
|
||||
/// <c>marker</c> parameter): a hidden per-character route file is named
|
||||
/// <c>--nav_Name_Server.af</c> — hidden prefix FIRST, marker SECOND —
|
||||
/// never <c>nav_--Name_Server.af</c>, which does not start with
|
||||
/// <see cref="HiddenPrefix"/> at all and so defeats every
|
||||
/// <c>StartsWith("--")</c> hidden-file check in this class.
|
||||
/// </summary>
|
||||
internal const string NavMarker = "nav_";
|
||||
|
||||
/// <summary>
|
||||
/// VTank's single per-character default filename
|
||||
/// (<c>uTank2/PluginCore.cs:3863-3865</c>): <c>--Name_Server.ext</c>.
|
||||
/// </summary>
|
||||
public static string AutoCharacterFileName(
|
||||
string characterName,
|
||||
string server,
|
||||
string extension) =>
|
||||
$"{HiddenPrefix}{characterName}_{server}.{extension.TrimStart('.')}";
|
||||
|
||||
/// <summary>
|
||||
/// Overload for a per-character auto file that ALSO carries a kind
|
||||
/// marker (currently only <see cref="NavMarker"/>, for
|
||||
/// <see cref="MossTankRouteProfileStore"/>'s route <c>.af</c>): the
|
||||
/// hidden prefix always comes first so the file stays hidden from every
|
||||
/// other character's picker exactly like the unmarked overload above —
|
||||
/// <c>--{marker}Name_Server.ext</c>, e.g. <c>--nav_Name_Server.af</c>.
|
||||
/// </summary>
|
||||
public static string AutoCharacterFileName(
|
||||
string characterName,
|
||||
string server,
|
||||
string extension,
|
||||
string marker) =>
|
||||
$"{HiddenPrefix}{marker}{characterName}_{server}.{extension.TrimStart('.')}";
|
||||
|
||||
/// <summary>
|
||||
/// The longer, trailing-underscore prefix
|
||||
/// (<c>uTank2/PluginCore.cs:933,3866</c>, field <c>dw</c>) that marks a
|
||||
/// *named sub-profile* belonging to one character, distinct from that
|
||||
/// character's single auto file above.
|
||||
/// </summary>
|
||||
public static string SubProfilePrefix(string characterName, string server) =>
|
||||
$"{HiddenPrefix}{characterName}_{server}_";
|
||||
|
||||
/// <summary>
|
||||
/// True when <paramref name="fileName"/> must be hidden from the
|
||||
/// cross-character profile picker for <paramref name="characterName"/>/
|
||||
/// <paramref name="server"/>: any <c>--</c>-prefixed name that is not
|
||||
/// this character's own sub-profile family
|
||||
/// (<c>uTank2/PluginCore.cs:7020,7072,7144</c>).
|
||||
/// </summary>
|
||||
public static bool IsHiddenFromOtherCharacters(
|
||||
string fileName,
|
||||
string characterName,
|
||||
string server) =>
|
||||
fileName.StartsWith(HiddenPrefix, StringComparison.Ordinal)
|
||||
&& !fileName.StartsWith(
|
||||
SubProfilePrefix(characterName, server),
|
||||
StringComparison.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// The "[Char] suffix" display form for one of this character's own
|
||||
/// sub-profiles (<c>uTank2/PluginCore.cs:7016-7046</c>): the
|
||||
/// <c>--Name_Server_</c> prefix and the file extension are both
|
||||
/// stripped. Returns <see langword="null"/> for a filename that is not
|
||||
/// one of this character's sub-profiles.
|
||||
/// </summary>
|
||||
public static string? TryDisplayName(
|
||||
string fileName,
|
||||
string characterName,
|
||||
string server)
|
||||
{
|
||||
string prefix = SubProfilePrefix(characterName, server);
|
||||
if (!fileName.StartsWith(prefix, StringComparison.Ordinal))
|
||||
return null;
|
||||
string withoutPrefix = fileName[prefix.Length..];
|
||||
int dot = withoutPrefix.LastIndexOf('.');
|
||||
string suffix = dot >= 0 ? withoutPrefix[..dot] : withoutPrefix;
|
||||
return suffix.Length == 0 ? null : $"[Char] {suffix}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One entry in a profile picker: the real on-disk file name, and the
|
||||
/// label VTank would show for it.
|
||||
/// </summary>
|
||||
public readonly record struct ProfileEntry(string FileName, string DisplayName);
|
||||
|
||||
/// <summary>
|
||||
/// VTank's settings-profile list (<c>a0()</c>,
|
||||
/// <c>uTank2/PluginCore.cs:7057-7115</c>): seeds
|
||||
/// <see cref="DefaultLabel"/> and <see cref="ByCharacterLabel"/> first,
|
||||
/// then every non-<c>--</c> <c>.usd</c> file, plus every one of this
|
||||
/// character's own <c>--Name_Server_*</c> sub-profiles shown as
|
||||
/// <c>[Char] suffix</c>.
|
||||
/// </summary>
|
||||
/// <param name="mineOnly">
|
||||
/// The "Mine only" checkbox (<c>cSettingsShowAll</c>/field <c>a9</c>).
|
||||
/// VTank's own predicate (<c>uTank2/PluginCore.cs:7020-7024</c>) is NOT
|
||||
/// "hide every shared file" — a shared (non-sub-profile) file is hidden
|
||||
/// only when it is neither unchecked NOR the currently selected file,
|
||||
/// so the profile actually in use never vanishes out from under the
|
||||
/// user just because they ticked the box afterward.
|
||||
/// </param>
|
||||
/// <param name="currentFileName">
|
||||
/// The file name currently assigned to <paramref name="characterName"/>
|
||||
/// (if any), exempted from <paramref name="mineOnly"/> filtering per the
|
||||
/// rule above. Comparison is ordinal (VTank's own file names are
|
||||
/// case-sensitive on the filesystems it ships for).
|
||||
/// </param>
|
||||
public static IReadOnlyList<ProfileEntry> ListSettingsProfiles(
|
||||
IPluginStorage storage,
|
||||
string characterName,
|
||||
string server,
|
||||
bool mineOnly,
|
||||
string? currentFileName = null)
|
||||
{
|
||||
var entries = new List<ProfileEntry>
|
||||
{
|
||||
new(string.Empty, DefaultLabel),
|
||||
new(string.Empty, ByCharacterLabel),
|
||||
};
|
||||
foreach (string fileName in EnumerateFileNames(storage, ".usd"))
|
||||
{
|
||||
string? subProfileDisplay = TryDisplayName(fileName, characterName, server);
|
||||
if (subProfileDisplay is not null)
|
||||
{
|
||||
entries.Add(new ProfileEntry(fileName, subProfileDisplay));
|
||||
continue;
|
||||
}
|
||||
if (fileName.StartsWith(HiddenPrefix, StringComparison.Ordinal))
|
||||
continue; // someone else's --Name_Server(.usd|_*) family.
|
||||
if (mineOnly
|
||||
&& !fileName.Equals(currentFileName, StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
entries.Add(new ProfileEntry(fileName, fileName));
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// VTank's navigation-profile list (<c>l()</c>,
|
||||
/// <c>uTank2/PluginCore.cs:7156-7185</c>): seeds
|
||||
/// <see cref="NoneLabel"/>/<see cref="ByCharacterLabel"/>, then every
|
||||
/// <see cref="NavMarker"/>-marked <c>.af</c> file that starts with
|
||||
/// neither <c>--</c> nor <c>~~</c>. Round 3 item 4: a Meta profile and a
|
||||
/// route share this same flat, single-extension directory — without
|
||||
/// the marker check this returned every Meta <c>.af</c> too.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<ProfileEntry> ListNavigationProfiles(IPluginStorage storage)
|
||||
{
|
||||
var entries = new List<ProfileEntry>
|
||||
{
|
||||
new(string.Empty, NoneLabel),
|
||||
new(string.Empty, ByCharacterLabel),
|
||||
};
|
||||
foreach (string fileName in EnumerateFileNames(storage, ".af"))
|
||||
{
|
||||
if (fileName.StartsWith(HiddenPrefix, StringComparison.Ordinal)
|
||||
|| fileName.StartsWith(NavHiddenPrefix, StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (!fileName.StartsWith(NavMarker, StringComparison.Ordinal))
|
||||
continue;
|
||||
entries.Add(new ProfileEntry(fileName, fileName));
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// VTank's meta-profile list (<c>ac()</c>,
|
||||
/// <c>uTank2/PluginCore.cs:7187+</c>): seeds
|
||||
/// <see cref="NoneLabel"/>/<see cref="ByCharacterLabel"/>, then every
|
||||
/// non-<c>--</c>, non-<see cref="NavMarker"/>-marked file (a meta and a
|
||||
/// nav profile share the same directory and extension here —
|
||||
/// <c>.af</c> — so the marker is what VTank's own separate
|
||||
/// <c>.met</c>/<c>.nav</c> extensions used to provide; round 3 item 4:
|
||||
/// without excluding <see cref="NavMarker"/> files this returned every
|
||||
/// route <c>.af</c> too).
|
||||
/// </summary>
|
||||
public static IReadOnlyList<ProfileEntry> ListMetaProfiles(IPluginStorage storage)
|
||||
{
|
||||
var entries = new List<ProfileEntry>
|
||||
{
|
||||
new(string.Empty, NoneLabel),
|
||||
new(string.Empty, ByCharacterLabel),
|
||||
};
|
||||
foreach (string fileName in EnumerateFileNames(storage, ".af"))
|
||||
{
|
||||
if (fileName.StartsWith(HiddenPrefix, StringComparison.Ordinal))
|
||||
continue;
|
||||
if (fileName.StartsWith(NavMarker, StringComparison.Ordinal))
|
||||
continue;
|
||||
entries.Add(new ProfileEntry(fileName, fileName));
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// VTank's real loot-profile list (<c>aa()</c>,
|
||||
/// <c>uTank2/PluginCore.cs:7127-7154</c>) seeds ONLY
|
||||
/// <see cref="NoneLabel"/> — retail has no per-character auto loot
|
||||
/// file and no "mine only" filter for loot at all (docs/research/vtank-kb/01-settings-and-profiles.md
|
||||
/// section 3: "the loot default has no equivalent auto-name; loot
|
||||
/// profiles default to none") — then every non-<c>--</c> file across
|
||||
/// whatever loot-profile extension family that build understands.
|
||||
/// Round 3 item 9: MossTank keeps its own established
|
||||
/// <see cref="ByCharacterLabel"/> convention (already load-bearing for
|
||||
/// Settings/Nav/Meta) as a SECOND seed entry for internal consistency
|
||||
/// across all four stores — a recorded adaptation (register), not a
|
||||
/// retail behavior — while still seeding retail's own
|
||||
/// <see cref="NoneLabel"/> first.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<ProfileEntry> ListLootProfiles(IPluginStorage storage)
|
||||
{
|
||||
var entries = new List<ProfileEntry>
|
||||
{
|
||||
new(string.Empty, NoneLabel),
|
||||
new(string.Empty, ByCharacterLabel),
|
||||
};
|
||||
foreach (string fileName in EnumerateFileNames(storage, ".utl"))
|
||||
{
|
||||
if (fileName.StartsWith(HiddenPrefix, StringComparison.Ordinal))
|
||||
continue;
|
||||
entries.Add(new ProfileEntry(fileName, fileName));
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The per-character spell-tracking cache filename (<c>dm</c> class,
|
||||
/// <c>refs/vtank/decompiled/dm.cs:391</c>): always
|
||||
/// <c>CharacterName_Server.ast</c> — no <c>"--"</c> prefix (unlike the
|
||||
/// auto settings/nav/meta files), not user-selectable, and note the
|
||||
/// concatenation ORDER (Name first, then Server) is the reverse of
|
||||
/// <see cref="CdfFileName"/>'s Server-then-Name order.
|
||||
/// </summary>
|
||||
public static string AstFileName(string characterName, string server) =>
|
||||
$"{characterName}_{server}.ast";
|
||||
|
||||
/// <summary>
|
||||
/// The per-character binding file's own name (<c>da</c> class,
|
||||
/// <c>refs/vtank/decompiled/da.cs:110</c>): always
|
||||
/// <c>Server_CharacterName.cdf</c> — Server first, then Name (the
|
||||
/// reverse of <see cref="AstFileName"/> and of the auto
|
||||
/// <c>--Name_Server.usd</c> naming).
|
||||
/// </summary>
|
||||
public static string CdfFileName(string characterName, string server) =>
|
||||
$"{server}_{characterName}.cdf";
|
||||
|
||||
/// <summary>The literal version header a valid <c>.cdf</c> starts with (<c>da.cs:15</c>).</summary>
|
||||
internal const string CdfHeader = "uTank2 CDF 1.0";
|
||||
|
||||
/// <summary>
|
||||
/// One character's currently-assigned profile filenames, read from its
|
||||
/// <c>.cdf</c> (<c>da.e()</c>, <c>da.cs:105-154</c>). <c>Meta</c> is
|
||||
/// <see langword="null"/> when the file is short (line 5 is present
|
||||
/// only when the stream isn't already at EOF, <c>da.cs:125-128</c> —
|
||||
/// an older <c>.cdf</c> predating meta support has no line 5 at all).
|
||||
/// </summary>
|
||||
public readonly record struct VtankCharacterBinding(
|
||||
string SettingsFileName,
|
||||
string LootFileName,
|
||||
string NavFileName,
|
||||
string? MetaFileName);
|
||||
|
||||
/// <summary>
|
||||
/// Reads <paramref name="characterName"/>/<paramref name="server"/>'s
|
||||
/// <c>.cdf</c> through <paramref name="storage"/>. Returns
|
||||
/// <see langword="null"/> when the storage is unavailable, the file is
|
||||
/// missing, or the file does not start with <see cref="CdfHeader"/> —
|
||||
/// real VTank treats a version mismatch identically to a missing file
|
||||
/// and falls back to that character's auto-created per-character
|
||||
/// defaults (<c>da.cs:113-121</c>), which this method leaves entirely
|
||||
/// to the caller rather than fabricating a default binding itself. The
|
||||
/// legacy <c>.uts</c>→<c>.usd</c> settings-filename rewrite
|
||||
/// (<c>da.cs:130-141</c>) is applied here so callers never see a
|
||||
/// <c>.uts</c> name.
|
||||
/// </summary>
|
||||
public static VtankCharacterBinding? TryReadCharacterBinding(
|
||||
IPluginStorage storage,
|
||||
string characterName,
|
||||
string server)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(storage);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(characterName);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(server);
|
||||
if (!storage.IsAvailable)
|
||||
return null;
|
||||
string? text = storage.ReadText(CdfFileName(characterName, server));
|
||||
if (text is null)
|
||||
return null;
|
||||
string[] lines = text
|
||||
.Replace("\r\n", "\n", StringComparison.Ordinal)
|
||||
.Split('\n');
|
||||
if (lines.Length < 4 || !string.Equals(lines[0], CdfHeader, StringComparison.Ordinal))
|
||||
return null;
|
||||
string settings = lines[1].EndsWith(".uts", StringComparison.OrdinalIgnoreCase)
|
||||
? string.Concat(lines[1].AsSpan(0, lines[1].Length - 4), ".usd")
|
||||
: lines[1];
|
||||
string? meta = lines.Length >= 5 && lines[4].Length > 0 ? lines[4] : null;
|
||||
return new VtankCharacterBinding(settings, lines[2], lines[3], meta);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes <paramref name="binding"/> as <paramref name="characterName"/>/
|
||||
/// <paramref name="server"/>'s <c>.cdf</c> (<c>da.q()</c>,
|
||||
/// <c>refs/vtank/decompiled/da.cs:156-164</c>): the version header, then
|
||||
/// settings/loot/nav on lines 2-4, then the meta filename on line 5 only
|
||||
/// when <see cref="VtankCharacterBinding.MetaFileName"/> is non-empty —
|
||||
/// matching <see cref="TryReadCharacterBinding"/>'s own line-5-is-optional
|
||||
/// parsing exactly. No-ops when the storage is unavailable.
|
||||
/// </summary>
|
||||
public static void WriteCharacterBinding(
|
||||
IPluginStorage storage,
|
||||
string characterName,
|
||||
string server,
|
||||
VtankCharacterBinding binding)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(storage);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(characterName);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(server);
|
||||
if (!storage.IsAvailable)
|
||||
return;
|
||||
var lines = new List<string>
|
||||
{
|
||||
CdfHeader,
|
||||
binding.SettingsFileName,
|
||||
binding.LootFileName,
|
||||
binding.NavFileName,
|
||||
};
|
||||
if (!string.IsNullOrEmpty(binding.MetaFileName))
|
||||
lines.Add(binding.MetaFileName);
|
||||
storage.WriteText(
|
||||
CdfFileName(characterName, server),
|
||||
string.Join("\r\n", lines) + "\r\n");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lists root-level file names matching <paramref name="extension"/>
|
||||
/// through <see cref="IPluginStorage.List"/> alone — no
|
||||
/// <c>System.IO</c>. VTank's own profile directory is flat, so a key
|
||||
/// containing '/' (meaning it lives in some deeper storage
|
||||
/// implementation's sub-directory) is not one of these files and is
|
||||
/// skipped rather than surfaced as a bogus profile name.
|
||||
/// </summary>
|
||||
private static IEnumerable<string> EnumerateFileNames(IPluginStorage storage, string extension)
|
||||
{
|
||||
if (!storage.IsAvailable)
|
||||
yield break;
|
||||
foreach (string key in storage.List(string.Empty)
|
||||
.Where(key => !key.Contains('/', StringComparison.Ordinal))
|
||||
.Where(key => key.EndsWith(extension, StringComparison.OrdinalIgnoreCase))
|
||||
.OrderBy(static key => key, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
yield return key;
|
||||
}
|
||||
}
|
||||
}
|
||||
542
src/AcDream.Plugins.MossTank/VtankSettingsProfileSerializer.cs
Normal file
542
src/AcDream.Plugins.MossTank/VtankSettingsProfileSerializer.cs
Normal file
|
|
@ -0,0 +1,542 @@
|
|||
using System.Globalization;
|
||||
using AcDream.Plugin.Abstractions;
|
||||
|
||||
namespace AcDream.Plugins.MossTank;
|
||||
|
||||
/// <summary>
|
||||
/// Loads/saves VTank's real <c>.usd</c> settings profile (the self-describing
|
||||
/// <c>gy</c>/<c>bd</c>/<c>y</c> grammar in <see cref="VtankDatabase"/>),
|
||||
/// mapping every one of the 137 <c>Settings</c> table rows
|
||||
/// (<see cref="VtankOptionCatalog.Names"/>) onto the live
|
||||
/// <see cref="CombatSettings"/>/<see cref="BuffSettings"/>/
|
||||
/// <see cref="VitalSettings"/>/<see cref="InventorySettings"/>/
|
||||
/// <see cref="NavigationSettings"/> fields by VTank name.
|
||||
///
|
||||
/// The value transforms (unit scaling, enum offsets) are the exact ones
|
||||
/// already verified in <c>MossTankPanel.SetMetaOption</c>/<c>GetMetaOption</c>
|
||||
/// (<c>MossTankPanel.cs:2442-3377</c>) against the decompiled VTank source
|
||||
/// per <c>docs/research/vtank-kb/01-settings-and-profiles.md</c> — this
|
||||
/// class duplicates those transforms rather than calling into
|
||||
/// <c>MossTankPanel</c> because the panel's methods operate on a live
|
||||
/// session (chat, expression state) that a file-format serializer must not
|
||||
/// depend on.
|
||||
///
|
||||
/// Every table other than <c>Settings</c> (MyMonsters, GemFoodItems,
|
||||
/// ExtraBuffSpells, AntiExtraBuffSpells, ItemUseSpecifiers,
|
||||
/// SettingsCategories, SettingsEnumInfo, AssistItems, BuffedItems, …) is
|
||||
/// preserved byte-for-byte: <see cref="Save"/> mutates the exact
|
||||
/// <see cref="VtankDatabase"/> object <see cref="Load"/> returned, and only
|
||||
/// replaces a <c>Settings</c> row's Value cell when the live setting's
|
||||
/// current value differs from what was parsed (a value-equality compare,
|
||||
/// not a text compare, so an untouched profile round-trips byte-for-byte
|
||||
/// even if VTank's own float formatting differs subtly from ours).
|
||||
/// </summary>
|
||||
internal static class VtankSettingsProfileSerializer
|
||||
{
|
||||
private const string SettingsTable = "Settings";
|
||||
|
||||
internal sealed class AllSettings
|
||||
{
|
||||
public required CombatSettings Combat { get; init; }
|
||||
public required BuffSettings Buffs { get; init; }
|
||||
public required VitalSettings Vitals { get; init; }
|
||||
public required InventorySettings Inventory { get; init; }
|
||||
public required NavigationSettings Navigation { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses <paramref name="text"/> and applies every recognized Settings
|
||||
/// row onto <paramref name="target"/>. Returns the parsed
|
||||
/// <see cref="VtankDatabase"/> so a later <see cref="Save"/> can
|
||||
/// preserve every other table (and every unrecognized Settings row)
|
||||
/// byte-for-byte. Throws <see cref="FormatException"/> with a
|
||||
/// <c>line N:</c>-prefixed message on the first structural error.
|
||||
/// </summary>
|
||||
public static VtankDatabase Load(string text, AllSettings target)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(target);
|
||||
VtankDatabase database = VtankDatabase.Parse(text);
|
||||
VtankTable? settings = database.Find(SettingsTable);
|
||||
if (settings is null)
|
||||
throw new FormatException("missing required 'Settings' table.");
|
||||
int nameColumn = settings.ColumnIndex("Setting");
|
||||
int valueColumn = settings.ColumnIndex("Value");
|
||||
if (nameColumn < 0 || valueColumn < 0)
|
||||
throw new FormatException("'Settings' table is missing Setting/Value columns.");
|
||||
|
||||
foreach (VtankRow row in settings.Rows)
|
||||
{
|
||||
string name = row.Cells[nameColumn].AsString();
|
||||
Apply(name, row.Cells[valueColumn], target);
|
||||
}
|
||||
return database;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Regenerates the <c>Settings</c> table's Value cells from
|
||||
/// <paramref name="source"/>'s current values (skipping any row whose
|
||||
/// value is unchanged from what <paramref name="document"/> already
|
||||
/// holds) and re-renders the whole database. Every other table, and any
|
||||
/// Settings row this serializer does not recognize, is untouched.
|
||||
/// </summary>
|
||||
public static string Save(VtankDatabase document, AllSettings source)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(document);
|
||||
ArgumentNullException.ThrowIfNull(source);
|
||||
VtankTable? settings = document.Find(SettingsTable);
|
||||
if (settings is null)
|
||||
throw new FormatException("missing required 'Settings' table.");
|
||||
int nameColumn = settings.ColumnIndex("Setting");
|
||||
int valueColumn = settings.ColumnIndex("Value");
|
||||
foreach (VtankRow row in settings.Rows)
|
||||
{
|
||||
string name = row.Cells[nameColumn].AsString();
|
||||
VtankCell? captured = Capture(name, source);
|
||||
if (captured is null)
|
||||
continue;
|
||||
VtankCell current = row.Cells[valueColumn];
|
||||
if (ValuesEqual(current, captured))
|
||||
continue;
|
||||
row.Cells[valueColumn] = captured;
|
||||
}
|
||||
return document.Render();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a brand-new database seeded from VTank's own shipped
|
||||
/// <c>defaultsettings.usd</c> (<see cref="VtankDefaultSettingsDatabase"/>)
|
||||
/// — every table (MyMonsters, GemFoodItems, ExtraBuffSpells,
|
||||
/// AntiExtraBuffSpells, ItemUseSpecifiers, SettingsCategories,
|
||||
/// SettingsEnumInfo, AssistItems, BuffedItems, RechargeHandlerSet, …),
|
||||
/// not a hand-typed 4-column Settings-only replica: a prior version of
|
||||
/// this method built only the Settings table, with an empty
|
||||
/// Description and a hardcoded SettingType of 1 (Bool) for every row
|
||||
/// regardless of the setting's real declared type. <see cref="Save"/>
|
||||
/// then rewrites every Settings row's Value cell from
|
||||
/// <paramref name="source"/>'s current values, exactly as it would for
|
||||
/// an existing profile being re-saved untouched.
|
||||
/// </summary>
|
||||
public static VtankDatabase CreateNew(AllSettings source)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(source);
|
||||
VtankDatabase database = VtankDefaultSettingsDatabase.Parse();
|
||||
_ = Save(database, source);
|
||||
return database;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a live numeric setting value to the exact
|
||||
/// <see cref="VtankCell"/> shape its declared <c>SettingType</c>
|
||||
/// requires — the fix for the fidelity defect where 17 <c>tInt</c>
|
||||
/// settings were written as <c>d</c> and 3 <c>tSingle</c> settings were
|
||||
/// written as <c>f</c> (VTank's <c>gy.e</c>/<c>gy.f</c> unbox helpers
|
||||
/// throw <see cref="InvalidCastException"/> on a tag/CLR-type mismatch).
|
||||
/// The tag is driven by <see cref="VtankOptionCatalog.DeclaredType"/>,
|
||||
/// never by the CLR type of the C# field the caller happens to store
|
||||
/// the value in.
|
||||
/// </summary>
|
||||
private static VtankCell Num(string name, double value) =>
|
||||
VtankOptionCatalog.DeclaredType(name) switch
|
||||
{
|
||||
VtankSettingValueType.Bool => VtankCell.Bool(value != 0d),
|
||||
VtankSettingValueType.Int or VtankSettingValueType.Enum =>
|
||||
VtankCell.Int((int)Math.Round(value, MidpointRounding.AwayFromZero)),
|
||||
VtankSettingValueType.Single => VtankCell.Float((float)value),
|
||||
VtankSettingValueType.String => VtankCell.String(value.ToString(CultureInfo.InvariantCulture)),
|
||||
_ => VtankCell.Double(value),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Exact compare on the cell's own type, with no tolerance anywhere —
|
||||
/// a <see cref="Capture"/> tag mismatch is a real bug, not something to
|
||||
/// paper over. Item I (slice-1 fix round) removed the one narrow
|
||||
/// exception this used to carry: every <c>tDouble</c>-declared distance
|
||||
/// setting (AttackDistance, ArcRange, …) that used to round-trip
|
||||
/// through a <c>float</c>-typed live field (CombatSettings.MaximumRange
|
||||
/// etc., costing precision below float's ~7-digit guarantee) now stores
|
||||
/// that value as a real <c>double</c> — the field widened to match its
|
||||
/// declared type instead of the compare being loosened to match the
|
||||
/// field. Physics/combat consumers that want a <c>float</c> cast it at
|
||||
/// their own use site.
|
||||
/// </summary>
|
||||
private static bool ValuesEqual(VtankCell a, VtankCell b)
|
||||
{
|
||||
if (a.Tag != b.Tag)
|
||||
return false;
|
||||
return a.Tag switch
|
||||
{
|
||||
"b" => a.AsBool() == b.AsBool(),
|
||||
"s" => a.AsString() == b.AsString(),
|
||||
"i" => a.AsInt() == b.AsInt(),
|
||||
"u" => a.AsUInt() == b.AsUInt(),
|
||||
"d" or "f" => a.AsDouble() == b.AsDouble(),
|
||||
_ => ReferenceEquals(a, b),
|
||||
};
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Apply: .usd cell -> live setting.
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/// <summary>Internal (not private) so the profile store can seed live settings directly from a parsed database (fresh/default profiles, migration).</summary>
|
||||
internal static void Apply(string rawName, VtankCell cell, AllSettings s)
|
||||
{
|
||||
if (!VtankOptionCatalog.IsKnown(rawName))
|
||||
return;
|
||||
string name = VtankOptionCatalog.Canonical(rawName).ToLowerInvariant();
|
||||
CombatSettings c = s.Combat;
|
||||
BuffSettings b = s.Buffs;
|
||||
VitalSettings v = s.Vitals;
|
||||
InventorySettings i = s.Inventory;
|
||||
NavigationSettings n = s.Navigation;
|
||||
switch (name)
|
||||
{
|
||||
case "enablelooting": i.Loot.Enabled = cell.AsBool(); break;
|
||||
case "enablenav": n.Enabled = cell.AsBool(); break;
|
||||
case "enablebuffing": b.Enabled = cell.AsBool(); break;
|
||||
case "enablecombat": c.Enabled = cell.AsBool(); break;
|
||||
case "spelldiffexcessthreshold-hunt": c.HuntSkillExcessOverDifficulty = cell.AsInt(); break;
|
||||
case "spelldiffexcessthreshold-buff": b.SkillExcessOverDifficulty = cell.AsInt(); break;
|
||||
case "arrowheadfletchdiffexcessthreshold": i.ArrowheadFletchDifficultyExcess = cell.AsInt(); break;
|
||||
case "recharge-norm-hitp": v.NormalHealth = cell.AsDouble() / 100d; break;
|
||||
case "recharge-norm-stam": v.NormalStamina = cell.AsDouble() / 100d; break;
|
||||
case "recharge-norm-mana": v.NormalMana = cell.AsDouble() / 100d; break;
|
||||
case "recharge-notarg-hitp": v.NoTargetHealth = cell.AsDouble() / 100d; break;
|
||||
case "recharge-notarg-stam": v.NoTargetStamina = cell.AsDouble() / 100d; break;
|
||||
case "recharge-notarg-mana": v.NoTargetMana = cell.AsDouble() / 100d; break;
|
||||
case "recharge-helper-hitp": v.HelperHealth = cell.AsDouble() / 100d; break;
|
||||
case "recharge-helper-stam": v.HelperStamina = cell.AsDouble() / 100d; break;
|
||||
case "recharge-helper-mana": v.HelperMana = cell.AsDouble() / 100d; break;
|
||||
case "dohelp": v.HelpOthers = cell.AsBool(); break;
|
||||
case "attackdistance": c.MaximumRange = cell.AsDouble() * 240d; break;
|
||||
case "attackminimumdistance": c.MinimumRange = cell.AsDouble() * 240d; break;
|
||||
case "approachdistance": c.ApproachDistance = cell.AsDouble() * 240d; break;
|
||||
case "ringdistance": c.RingDistance = cell.AsDouble() * 240d; break;
|
||||
case "corpseapproachrange-max": i.Loot.CorpseApproachRange = cell.AsDouble() * 240d; break;
|
||||
case "corpseapproachrange-min": i.Loot.CorpseMinimumApproachRange = cell.AsDouble() * 240d; break;
|
||||
case "navclosestoprange": n.MinimumDistanceMeters = cell.AsDouble() * 240d; break;
|
||||
case "navfarstoprange": n.MaximumDistanceMeters = cell.AsDouble() * 240d; break;
|
||||
case "useportaldistance": n.PortalUseDistanceMeters = cell.AsDouble() * 240d; break;
|
||||
case "helperdistancehitp": v.HelperHealthDistance = cell.AsDouble() * 240d; break;
|
||||
case "helperdistancestam": v.HelperStaminaDistance = cell.AsDouble() * 240d; break;
|
||||
case "helperdistancemana": v.HelperManaDistance = cell.AsDouble() * 240d; break;
|
||||
case "minimumringtargets": c.MinimumRingTargets = cell.AsInt(); break;
|
||||
case "defaultmeleeattackheight": c.AttackHeight = (PluginAttackHeight)cell.AsInt(); break;
|
||||
case "castdispelself": v.CastDispelSelf = cell.AsBool(); break;
|
||||
case "usedispelitems": v.UseDispelItems = cell.AsBool(); break;
|
||||
case "autocram": i.AutoCram = cell.AsBool(); break;
|
||||
case "autostack": i.AutoStack = cell.AsBool(); break;
|
||||
case "readunknownscrolls": i.Loot.ReadUnknownScrolls = cell.AsBool(); break;
|
||||
case "usedispeldrum": v.UseDispelDrum = cell.AsBool(); break;
|
||||
case "switchwandstodebuff": c.SwitchWandsToDebuff = cell.AsBool(); break;
|
||||
case "autocraftitems": i.AutoCraftItems = cell.AsBool(); break;
|
||||
case "usehealersheart": v.UseHealersHeart = cell.AsBool(); break;
|
||||
case "jumpoutwandcasting": c.JumpOutWandCasting = cell.AsBool(); break;
|
||||
case "lootallcorpses": i.Loot.LootAllCorpses = cell.AsBool(); break;
|
||||
case "lootfellowcorpses": i.Loot.LootFellowCorpses = cell.AsBool(); break;
|
||||
case "dojiggle": c.DoJiggle = cell.AsBool(); break;
|
||||
case "randomhelperbuffs": b.RandomHelperBuffs = cell.AsBool(); break;
|
||||
case "randomhelperintervalseconds": b.RandomHelperIntervalSeconds = cell.AsDouble(); break;
|
||||
case "idlepeacemode": c.IdlePeaceMode = cell.AsBool(); break;
|
||||
case "targetlock": c.TargetLock = cell.AsBool(); break;
|
||||
case "stopmacroondeath": c.StopMacroOnDeath = cell.AsBool(); break;
|
||||
case "usearcs": c.UseArcs = (UseArcsMode)cell.AsInt(); break;
|
||||
case "arcrange": c.ArcRange = cell.AsDouble() * 240d; break;
|
||||
case "targetselectmethod": c.SelectionMethod = (TargetSelectionMethod)(cell.AsInt() - 1); break;
|
||||
case "targetselectanglerange": c.TargetSelectAngleRange = cell.AsDouble() * 240d; break;
|
||||
case "idlebufftopoff": b.IdleBuffTopoff = cell.AsBool(); break;
|
||||
case "idlebufftopofftimeseconds": b.IdleBuffTopoffSeconds = cell.AsDouble(); break;
|
||||
case "rebufftimeremainingseconds": b.RebuffWhenUnderSeconds = cell.AsDouble(); break;
|
||||
case "refillwornmana": i.RefillWornMana = cell.AsBool(); break;
|
||||
case "refillwornmana-item-manapercent": i.RefillWornManaPercent = cell.AsInt(); break;
|
||||
case "buffprofile-prots": b.ProtectionElements = cell.AsString(); break;
|
||||
case "buffprofile-banes": b.BaneElements = cell.AsString(); break;
|
||||
case "buffprofile_prots": b.ProtectionProfileMode = cell.AsInt(); break;
|
||||
case "buffprofile_banes": b.BaneProfileMode = cell.AsInt(); break;
|
||||
case "debuffeachfirst": c.DebuffEachFirst = (DebuffEachFirst)cell.AsInt(); break;
|
||||
case "autoattackpower": c.AutoAttackPower = cell.AsBool(); break;
|
||||
case "lootpriorityboost": i.Loot.PriorityBoost = cell.AsBool(); break;
|
||||
case "corpsecachetimeoutminutes": i.Loot.CorpseCacheTimeoutMinutes = cell.AsDouble(); break;
|
||||
case "corpseitemappearancetimeoutseconds": i.Loot.CorpseItemAppearanceTimeoutSeconds = cell.AsDouble(); break;
|
||||
case "corpseitemidtimeoutseconds": i.Loot.CorpseItemIdentifyTimeoutSeconds = cell.AsDouble(); break;
|
||||
case "debuffselectionmethod": c.DebuffSelectionMethod = (DebuffSelectionMethod)cell.AsInt(); break;
|
||||
case "manastonelootcount": i.Loot.ManaStoneLootCount = cell.AsInt(); break;
|
||||
case "manatankminimummana": i.Loot.ManaTankMinimumMana = cell.AsInt(); break;
|
||||
case "splitpeas": i.SplitPeas = cell.AsBool(); break;
|
||||
case "spellcompmin-critical": i.CriticalComponentMinimum = cell.AsInt(); break;
|
||||
case "spellcompmin-normal": i.NormalComponentMinimum = cell.AsInt(); break;
|
||||
case "spellcompmin-idle": i.IdleComponentMinimum = cell.AsInt(); break;
|
||||
case "rechargeboosttimeseconds": v.RechargeBoostTimeSeconds = cell.AsDouble(); break;
|
||||
case "rechargeboostamount": v.RechargeBoostAmount = cell.AsInt(); break;
|
||||
case "usespecialammo": c.UseSpecialAmmo = cell.AsInt(); break;
|
||||
case "opendoors": n.OpenDoors = cell.AsBool(); break;
|
||||
case "dooridrange": n.DoorIdentifyRangeMeters = cell.AsDouble() * 240d; break;
|
||||
case "dooropenrange": n.DoorOpenRangeMeters = cell.AsDouble() * 240d; break;
|
||||
case "doorlockpickdiffexcessthreshold": n.DoorLockpickExcessThreshold = cell.AsInt(); break;
|
||||
case "manachargeswhenoff": i.ManaChargesWhenOff = cell.AsBool(); break;
|
||||
case "autofellowmanagement": c.AutoFellowManagement = cell.AsBool(); break;
|
||||
case "minimumhealkitsuccesschance": v.MinimumHealKitSuccessChance = cell.AsInt(); break;
|
||||
case "usekitsinmagicmode": v.UseKitsInMagicMode = cell.AsBool(); break;
|
||||
case "staminatohealthmultiplier": v.StaminaToHealthMultiplier = cell.AsDouble(); break;
|
||||
case "manatohealthmultiplier": v.ManaToHealthMultiplier = cell.AsDouble(); break;
|
||||
case "navpriorityboost": n.Priority = cell.AsBool(); break;
|
||||
case "deleteghostmonsters": c.DeleteGhostMonsters = cell.AsBool(); break;
|
||||
case "ghostmonsterspellattemptcount": c.GhostMonsterSpellAttemptCount = cell.AsInt(); break;
|
||||
case "whoyougonnacall": c.WhoYouGonnaCall = cell.AsBool(); break;
|
||||
case "blacklistmonsterattemptcount": c.BlacklistMonsterAttemptCount = cell.AsInt(); break;
|
||||
case "blacklistmonstertimeoutseconds": c.BlacklistMonsterTimeoutSeconds = cell.AsDouble(); break;
|
||||
case "combinesalvage": i.Loot.CombineSalvage = cell.AsBool(); break;
|
||||
case "lootonlyrarecorpses": i.Loot.LootOnlyRareCorpses = cell.AsBool(); break;
|
||||
case "deleteghostmonstersbyhptracker": c.DeleteGhostMonstersByHealthTracker = cell.AsBool(); break;
|
||||
case "ghostdeletehptrackerseconds": c.GhostDeleteHealthTrackerSeconds = cell.AsDouble(); break;
|
||||
case "gotopeacemodetousekits": v.GoToPeaceModeToUseKits = cell.AsBool(); break;
|
||||
case "userecklessness": c.UseRecklessness = cell.AsBool(); break;
|
||||
case "debuffprecastseconds": c.DebuffPrecastSeconds = cell.AsDouble(); break;
|
||||
case "clearlevelboostflagoncast": v.ClearLevelBoostFlagOnCast = cell.AsBool(); break;
|
||||
case "idlecraftcount_healthkits": i.IdleHealthKitCount = cell.AsInt(); break;
|
||||
case "idlecraftcount_stamkits": i.IdleStaminaKitCount = cell.AsInt(); break;
|
||||
case "idlecraftcount_manakits": i.IdleManaKitCount = cell.AsInt(); break;
|
||||
case "idlecraftcount_healthfood": i.IdleHealthFoodCount = cell.AsInt(); break;
|
||||
case "idlecraftcount_stamfood": i.IdleStaminaFoodCount = cell.AsInt(); break;
|
||||
case "idlecraftcount_manafood": i.IdleManaFoodCount = cell.AsInt(); break;
|
||||
case "buffcastrecast_seconds": b.BuffCastRecastSeconds = cell.AsDouble(); break;
|
||||
case "buffcastrecastreset_seconds": b.BuffCastRecastResetSeconds = cell.AsDouble(); break;
|
||||
case "enablemeta": break; // live MetaEngine.Enabled, not a stored settings field.
|
||||
case "blacklistedspellcomps":
|
||||
b.BlacklistedSpellComponents = cell.AsString();
|
||||
c.BlacklistedSpellComponents = cell.AsString();
|
||||
break;
|
||||
case "droptopeacemoderetrycount": v.DropToPeaceModeRetryCount = cell.AsInt(); break;
|
||||
case "followaroundcorners": n.FollowAroundCorners = cell.AsBool(); break;
|
||||
case "blacklistcorpseopenattemptcount": i.Loot.BlacklistCorpseOpenAttemptCount = cell.AsInt(); break;
|
||||
case "blacklistcorpseopentimeoutseconds": i.Loot.BlacklistCorpseOpenTimeoutSeconds = cell.AsDouble(); break;
|
||||
case "summonpets": c.SummonPets = cell.AsBool(); break;
|
||||
case "petrangemode": c.PetRangeMode = (PetRangeMode)cell.AsInt(); break;
|
||||
case "petcustomrange": c.PetCustomRange = cell.AsDouble() * 240d; break;
|
||||
case "petrefillcount-idle": c.PetRefillCountIdle = cell.AsInt(); break;
|
||||
case "petrefillcount-normal": c.PetRefillCountNormal = cell.AsInt(); break;
|
||||
case "corpseopentimeoutseconds": i.Loot.CorpseOpenTimeoutSeconds = cell.AsDouble(); break;
|
||||
case "petmonsterdensity": c.PetMonsterDensity = cell.AsInt(); break;
|
||||
case "corpselootitemmaxattempts": i.Loot.CorpseLootItemMaxAttempts = cell.AsInt(); break;
|
||||
case "fastcastbuffs": b.FastCastBuffs = cell.AsBool(); break;
|
||||
case "usebreakableturnto": c.UseBreakableTurnTo = cell.AsBool(); break;
|
||||
case "useprojectileawareness": c.UseProjectileAwareness = cell.AsBool(); break;
|
||||
case "collisionprojectileradius": c.CollisionProjectileRadius = cell.AsDouble(); break;
|
||||
case "collisionstepdistance": c.CollisionStepDistance = cell.AsDouble(); break;
|
||||
case "showcollisiondebug": c.ShowCollisionDebug = cell.AsBool(); break;
|
||||
case "maximumcollisioncheckspertick": c.MaximumCollisionChecksPerTick = cell.AsInt(); break;
|
||||
case "spellrangefudge": c.SpellRangeFudge = cell.AsDouble(); break;
|
||||
case "buffwithuntrained-item": b.BuffWithUntrainedItemSkill = cell.AsInt(); break;
|
||||
case "buffwithuntrained-creature": b.BuffWithUntrainedCreatureSkill = cell.AsInt(); break;
|
||||
case "buffwithuntrained-life": b.BuffWithUntrainedLifeSkill = cell.AsInt(); break;
|
||||
case "allowdebufffallback": c.AllowDebuffFallback = cell.AsBool(); break;
|
||||
case "rechargehandlerset":
|
||||
if (cell.Tag == "TABLE" && cell.Table is { } table)
|
||||
v.RechargeHandlerRows = ParseRechargeHandlerSet(table);
|
||||
break;
|
||||
default: break;
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Capture: live setting -> .usd cell. Mirror of Apply above; a name
|
||||
// Apply ignores (enablemeta, rechargehandlerset) also returns null here
|
||||
// so Save leaves that row's original cell untouched.
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/// <summary>Internal (not private) solely so the 137-setting coverage test can call it directly.</summary>
|
||||
internal static VtankCell? Capture(string rawName, AllSettings s)
|
||||
{
|
||||
if (!VtankOptionCatalog.IsKnown(rawName))
|
||||
return null;
|
||||
string name = VtankOptionCatalog.Canonical(rawName).ToLowerInvariant();
|
||||
CombatSettings c = s.Combat;
|
||||
BuffSettings b = s.Buffs;
|
||||
VitalSettings v = s.Vitals;
|
||||
InventorySettings i = s.Inventory;
|
||||
NavigationSettings n = s.Navigation;
|
||||
return name switch
|
||||
{
|
||||
"enablelooting" => VtankCell.Bool(i.Loot.Enabled),
|
||||
"enablenav" => VtankCell.Bool(n.Enabled),
|
||||
"enablebuffing" => VtankCell.Bool(b.Enabled),
|
||||
"enablecombat" => VtankCell.Bool(c.Enabled),
|
||||
"spelldiffexcessthreshold-hunt" => Num(name, c.HuntSkillExcessOverDifficulty),
|
||||
"spelldiffexcessthreshold-buff" => Num(name, b.SkillExcessOverDifficulty),
|
||||
"arrowheadfletchdiffexcessthreshold" => Num(name, i.ArrowheadFletchDifficultyExcess),
|
||||
"recharge-norm-hitp" => Num(name, v.NormalHealth * 100d),
|
||||
"recharge-norm-stam" => Num(name, v.NormalStamina * 100d),
|
||||
"recharge-norm-mana" => Num(name, v.NormalMana * 100d),
|
||||
"recharge-notarg-hitp" => Num(name, v.NoTargetHealth * 100d),
|
||||
"recharge-notarg-stam" => Num(name, v.NoTargetStamina * 100d),
|
||||
"recharge-notarg-mana" => Num(name, v.NoTargetMana * 100d),
|
||||
"recharge-helper-hitp" => Num(name, v.HelperHealth * 100d),
|
||||
"recharge-helper-stam" => Num(name, v.HelperStamina * 100d),
|
||||
"recharge-helper-mana" => Num(name, v.HelperMana * 100d),
|
||||
"dohelp" => VtankCell.Bool(v.HelpOthers),
|
||||
"attackdistance" => Num(name, c.MaximumRange / 240d),
|
||||
"attackminimumdistance" => Num(name, c.MinimumRange / 240d),
|
||||
"approachdistance" => Num(name, c.ApproachDistance / 240d),
|
||||
"ringdistance" => Num(name, c.RingDistance / 240d),
|
||||
"corpseapproachrange-max" => Num(name, i.Loot.CorpseApproachRange / 240d),
|
||||
"corpseapproachrange-min" => Num(name, i.Loot.CorpseMinimumApproachRange / 240d),
|
||||
"navclosestoprange" => Num(name, n.MinimumDistanceMeters / 240d),
|
||||
"navfarstoprange" => Num(name, n.MaximumDistanceMeters / 240d),
|
||||
"useportaldistance" => Num(name, n.PortalUseDistanceMeters / 240d),
|
||||
"helperdistancehitp" => Num(name, v.HelperHealthDistance / 240d),
|
||||
"helperdistancestam" => Num(name, v.HelperStaminaDistance / 240d),
|
||||
"helperdistancemana" => Num(name, v.HelperManaDistance / 240d),
|
||||
"minimumringtargets" => Num(name, c.MinimumRingTargets),
|
||||
"defaultmeleeattackheight" => Num(name, (int)c.AttackHeight),
|
||||
"castdispelself" => VtankCell.Bool(v.CastDispelSelf),
|
||||
"usedispelitems" => VtankCell.Bool(v.UseDispelItems),
|
||||
"autocram" => VtankCell.Bool(i.AutoCram),
|
||||
"autostack" => VtankCell.Bool(i.AutoStack),
|
||||
"readunknownscrolls" => VtankCell.Bool(i.Loot.ReadUnknownScrolls),
|
||||
"usedispeldrum" => VtankCell.Bool(v.UseDispelDrum),
|
||||
"switchwandstodebuff" => VtankCell.Bool(c.SwitchWandsToDebuff),
|
||||
"autocraftitems" => VtankCell.Bool(i.AutoCraftItems),
|
||||
"usehealersheart" => VtankCell.Bool(v.UseHealersHeart),
|
||||
"jumpoutwandcasting" => VtankCell.Bool(c.JumpOutWandCasting),
|
||||
"lootallcorpses" => VtankCell.Bool(i.Loot.LootAllCorpses),
|
||||
"lootfellowcorpses" => VtankCell.Bool(i.Loot.LootFellowCorpses),
|
||||
"dojiggle" => VtankCell.Bool(c.DoJiggle),
|
||||
"randomhelperbuffs" => VtankCell.Bool(b.RandomHelperBuffs),
|
||||
"randomhelperintervalseconds" => Num(name, b.RandomHelperIntervalSeconds),
|
||||
"idlepeacemode" => VtankCell.Bool(c.IdlePeaceMode),
|
||||
"targetlock" => VtankCell.Bool(c.TargetLock),
|
||||
"stopmacroondeath" => VtankCell.Bool(c.StopMacroOnDeath),
|
||||
"usearcs" => Num(name, (int)c.UseArcs),
|
||||
"arcrange" => Num(name, c.ArcRange / 240d),
|
||||
"targetselectmethod" => Num(name, (int)c.SelectionMethod + 1),
|
||||
"targetselectanglerange" => Num(name, c.TargetSelectAngleRange / 240d),
|
||||
"idlebufftopoff" => VtankCell.Bool(b.IdleBuffTopoff),
|
||||
"idlebufftopofftimeseconds" => Num(name, b.IdleBuffTopoffSeconds),
|
||||
"rebufftimeremainingseconds" => Num(name, b.RebuffWhenUnderSeconds),
|
||||
"refillwornmana" => VtankCell.Bool(i.RefillWornMana),
|
||||
"refillwornmana-item-manapercent" => Num(name, i.RefillWornManaPercent),
|
||||
"buffprofile-prots" => VtankCell.String(b.ProtectionElements),
|
||||
"buffprofile-banes" => VtankCell.String(b.BaneElements),
|
||||
"buffprofile_prots" => Num(name, b.ProtectionProfileMode),
|
||||
"buffprofile_banes" => Num(name, b.BaneProfileMode),
|
||||
"debuffeachfirst" => Num(name, (int)c.DebuffEachFirst),
|
||||
"autoattackpower" => VtankCell.Bool(c.AutoAttackPower),
|
||||
"lootpriorityboost" => VtankCell.Bool(i.Loot.PriorityBoost),
|
||||
"corpsecachetimeoutminutes" => Num(name, i.Loot.CorpseCacheTimeoutMinutes),
|
||||
"corpseitemappearancetimeoutseconds" => Num(name, i.Loot.CorpseItemAppearanceTimeoutSeconds),
|
||||
"corpseitemidtimeoutseconds" => Num(name, i.Loot.CorpseItemIdentifyTimeoutSeconds),
|
||||
"debuffselectionmethod" => Num(name, (int)c.DebuffSelectionMethod),
|
||||
"manastonelootcount" => Num(name, i.Loot.ManaStoneLootCount),
|
||||
"manatankminimummana" => Num(name, i.Loot.ManaTankMinimumMana),
|
||||
"splitpeas" => VtankCell.Bool(i.SplitPeas),
|
||||
"spellcompmin-critical" => Num(name, i.CriticalComponentMinimum),
|
||||
"spellcompmin-normal" => Num(name, i.NormalComponentMinimum),
|
||||
"spellcompmin-idle" => Num(name, i.IdleComponentMinimum),
|
||||
"rechargeboosttimeseconds" => Num(name, v.RechargeBoostTimeSeconds),
|
||||
"rechargeboostamount" => Num(name, v.RechargeBoostAmount),
|
||||
"usespecialammo" => Num(name, c.UseSpecialAmmo),
|
||||
"opendoors" => VtankCell.Bool(n.OpenDoors),
|
||||
"dooridrange" => Num(name, n.DoorIdentifyRangeMeters / 240d),
|
||||
"dooropenrange" => Num(name, n.DoorOpenRangeMeters / 240d),
|
||||
"doorlockpickdiffexcessthreshold" => Num(name, n.DoorLockpickExcessThreshold),
|
||||
"manachargeswhenoff" => VtankCell.Bool(i.ManaChargesWhenOff),
|
||||
"autofellowmanagement" => VtankCell.Bool(c.AutoFellowManagement),
|
||||
"minimumhealkitsuccesschance" => Num(name, v.MinimumHealKitSuccessChance),
|
||||
"usekitsinmagicmode" => VtankCell.Bool(v.UseKitsInMagicMode),
|
||||
"staminatohealthmultiplier" => Num(name, v.StaminaToHealthMultiplier),
|
||||
"manatohealthmultiplier" => Num(name, v.ManaToHealthMultiplier),
|
||||
"navpriorityboost" => VtankCell.Bool(n.Priority),
|
||||
"deleteghostmonsters" => VtankCell.Bool(c.DeleteGhostMonsters),
|
||||
"ghostmonsterspellattemptcount" => Num(name, c.GhostMonsterSpellAttemptCount),
|
||||
"whoyougonnacall" => VtankCell.Bool(c.WhoYouGonnaCall),
|
||||
"blacklistmonsterattemptcount" => Num(name, c.BlacklistMonsterAttemptCount),
|
||||
"blacklistmonstertimeoutseconds" => Num(name, c.BlacklistMonsterTimeoutSeconds),
|
||||
"combinesalvage" => VtankCell.Bool(i.Loot.CombineSalvage),
|
||||
"lootonlyrarecorpses" => VtankCell.Bool(i.Loot.LootOnlyRareCorpses),
|
||||
"deleteghostmonstersbyhptracker" => VtankCell.Bool(c.DeleteGhostMonstersByHealthTracker),
|
||||
"ghostdeletehptrackerseconds" => Num(name, c.GhostDeleteHealthTrackerSeconds),
|
||||
"gotopeacemodetousekits" => VtankCell.Bool(v.GoToPeaceModeToUseKits),
|
||||
"userecklessness" => VtankCell.Bool(c.UseRecklessness),
|
||||
"debuffprecastseconds" => Num(name, c.DebuffPrecastSeconds),
|
||||
"clearlevelboostflagoncast" => VtankCell.Bool(v.ClearLevelBoostFlagOnCast),
|
||||
"idlecraftcount_healthkits" => Num(name, i.IdleHealthKitCount),
|
||||
"idlecraftcount_stamkits" => Num(name, i.IdleStaminaKitCount),
|
||||
"idlecraftcount_manakits" => Num(name, i.IdleManaKitCount),
|
||||
"idlecraftcount_healthfood" => Num(name, i.IdleHealthFoodCount),
|
||||
"idlecraftcount_stamfood" => Num(name, i.IdleStaminaFoodCount),
|
||||
"idlecraftcount_manafood" => Num(name, i.IdleManaFoodCount),
|
||||
"buffcastrecast_seconds" => Num(name, b.BuffCastRecastSeconds),
|
||||
"buffcastrecastreset_seconds" => Num(name, b.BuffCastRecastResetSeconds),
|
||||
"blacklistedspellcomps" => VtankCell.String(b.BlacklistedSpellComponents),
|
||||
"droptopeacemoderetrycount" => Num(name, v.DropToPeaceModeRetryCount),
|
||||
"followaroundcorners" => VtankCell.Bool(n.FollowAroundCorners),
|
||||
"blacklistcorpseopenattemptcount" => Num(name, i.Loot.BlacklistCorpseOpenAttemptCount),
|
||||
"blacklistcorpseopentimeoutseconds" => Num(name, i.Loot.BlacklistCorpseOpenTimeoutSeconds),
|
||||
"summonpets" => VtankCell.Bool(c.SummonPets),
|
||||
"petrangemode" => Num(name, (int)c.PetRangeMode),
|
||||
"petcustomrange" => Num(name, c.PetCustomRange / 240d),
|
||||
"petrefillcount-idle" => Num(name, c.PetRefillCountIdle),
|
||||
"petrefillcount-normal" => Num(name, c.PetRefillCountNormal),
|
||||
"corpseopentimeoutseconds" => Num(name, i.Loot.CorpseOpenTimeoutSeconds),
|
||||
"petmonsterdensity" => Num(name, c.PetMonsterDensity),
|
||||
"corpselootitemmaxattempts" => Num(name, i.Loot.CorpseLootItemMaxAttempts),
|
||||
"fastcastbuffs" => VtankCell.Bool(b.FastCastBuffs),
|
||||
"usebreakableturnto" => VtankCell.Bool(c.UseBreakableTurnTo),
|
||||
"useprojectileawareness" => VtankCell.Bool(c.UseProjectileAwareness),
|
||||
"collisionprojectileradius" => Num(name, c.CollisionProjectileRadius),
|
||||
"collisionstepdistance" => Num(name, c.CollisionStepDistance),
|
||||
"showcollisiondebug" => VtankCell.Bool(c.ShowCollisionDebug),
|
||||
"maximumcollisioncheckspertick" => Num(name, c.MaximumCollisionChecksPerTick),
|
||||
"spellrangefudge" => Num(name, c.SpellRangeFudge),
|
||||
"buffwithuntrained-item" => Num(name, b.BuffWithUntrainedItemSkill),
|
||||
"buffwithuntrained-creature" => Num(name, b.BuffWithUntrainedCreatureSkill),
|
||||
"buffwithuntrained-life" => Num(name, b.BuffWithUntrainedLifeSkill),
|
||||
"allowdebufffallback" => VtankCell.Bool(c.AllowDebuffFallback),
|
||||
_ => null, // "enablemeta" (live engine state) and "rechargehandlerset"
|
||||
// (no write path exists in real VTank either, section 2 row 137)
|
||||
// are deliberately left untouched.
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses the RechargeHandlerSet nested TABLE (5 columns:
|
||||
/// Vital/HandlerString/MinPercent/MaxPercent/Stance — verified against
|
||||
/// the live <c>defaultsettings.usd</c> fixture, 26 rows).
|
||||
/// </summary>
|
||||
internal static RechargeHandlerRow[] ParseRechargeHandlerSet(VtankTable table)
|
||||
{
|
||||
int vitalColumn = table.ColumnIndex("Vital");
|
||||
int handlerColumn = table.ColumnIndex("HandlerString");
|
||||
int minColumn = table.ColumnIndex("MinPercent");
|
||||
int maxColumn = table.ColumnIndex("MaxPercent");
|
||||
int stanceColumn = table.ColumnIndex("Stance");
|
||||
if (vitalColumn < 0 || handlerColumn < 0 || minColumn < 0
|
||||
|| maxColumn < 0 || stanceColumn < 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
var rows = new RechargeHandlerRow[table.Rows.Count];
|
||||
for (int i = 0; i < table.Rows.Count; i++)
|
||||
{
|
||||
VtankRow row = table.Rows[i];
|
||||
rows[i] = new RechargeHandlerRow(
|
||||
row.Cells[vitalColumn].AsInt(),
|
||||
row.Cells[handlerColumn].AsString(),
|
||||
row.Cells[minColumn].AsInt(),
|
||||
row.Cells[maxColumn].AsInt(),
|
||||
row.Cells[stanceColumn].AsInt());
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
// No RenderRechargeHandlerSet/write-back helper exists here deliberately:
|
||||
// real VTank has NO save path for this table at all (docs/research/
|
||||
// vtank-kb/01-settings-and-profiles.md section 2, row 137 — tCustom has
|
||||
// no case in the Advanced Options editor's display/edit switch or in
|
||||
// "/vt opt set"; the table is owned by a dedicated cRechargeManager
|
||||
// object, not any control that persists through the generic Settings
|
||||
// save path). Writing it back would be a MossTank invention, not a
|
||||
// retail port. A prior version of this file had an unused
|
||||
// RenderRechargeHandlerSet helper for exactly that invention; it was
|
||||
// deleted rather than wired in for this reason.
|
||||
}
|
||||
438
src/AcDream.Plugins.MossTank/VtankUsdDocument.cs
Normal file
438
src/AcDream.Plugins.MossTank/VtankUsdDocument.cs
Normal file
|
|
@ -0,0 +1,438 @@
|
|||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
|
||||
namespace AcDream.Plugins.MossTank;
|
||||
|
||||
/// <summary>
|
||||
/// The self-describing recursive text-database grammar VTank uses for
|
||||
/// <c>.usd</c> settings profiles and <c>.ast</c> per-character caches. Ported
|
||||
/// from the decompiled <c>gy</c>/<c>cw</c>/<c>bd</c>/<c>y</c> classes
|
||||
/// documented in <c>docs/research/vtank-kb/01-settings-and-profiles.md</c>
|
||||
/// section 1: <c>refs/vtank/decompiled/gy.cs:20-56</c> (typed cell),
|
||||
/// <c>cw.cs:9-40</c> (row), <c>bd.cs:364-395</c> (table), <c>y.cs:87-104</c>
|
||||
/// (whole database). Plain text, CRLF line endings, no compression, no
|
||||
/// checksum, no top-level version header.
|
||||
///
|
||||
/// One cell (<c>gy</c>) is a type-tag line followed by zero or more value
|
||||
/// lines: <c>d</c>=double, <c>i</c>=int, <c>u</c>=uint, <c>f</c>=float,
|
||||
/// <c>s</c>=string (the raw next line, possibly empty), <c>b</c>=bool
|
||||
/// (<c>"True"</c>/<c>"False"</c>), <c>TABLE</c>=a nested table (recurse into
|
||||
/// <see cref="VtankTable"/>), <c>ba</c>=a length-prefixed raw-character blob
|
||||
/// (an int line for the character count, then that many raw characters,
|
||||
/// not lines, so embedded newlines survive — see <see cref="VtankLineCursor.ReadBlob"/>).
|
||||
/// Any other tag (<c>y.cs:28-41</c> only ever registers <c>TABLE</c> and
|
||||
/// <c>ba</c> as named custom types) is genuinely unrecognized to VTank
|
||||
/// itself: <c>gy.cs:50-55</c> (read) consumes ONLY the tag line and
|
||||
/// <c>gy.cs:98-101</c> (write) emits ONLY a tag line ("0" for a void cell)
|
||||
/// — no value line either way. This is preserved verbatim so an
|
||||
/// unrecognized custom type still round-trips byte-for-byte.
|
||||
/// </summary>
|
||||
internal sealed class VtankCell
|
||||
{
|
||||
// The only tags VTank's gy.a(TextReader)/gy.a(TextWriter) treat as a
|
||||
// tag+value pair (gy.cs:20-56, gy.cs:60-92). TABLE and ba are each
|
||||
// handled by their own dedicated branch; everything else is an
|
||||
// unrecognized/void tag that carries no value line at all.
|
||||
private static readonly HashSet<string> KnownScalarTags = ["d", "i", "u", "f", "s", "b"];
|
||||
|
||||
internal VtankCell() { }
|
||||
|
||||
/// <summary>The raw type tag: <c>d</c>/<c>i</c>/<c>u</c>/<c>f</c>/<c>s</c>/<c>b</c>/<c>TABLE</c>/<c>ba</c>/other.</summary>
|
||||
public required string Tag { get; init; }
|
||||
|
||||
/// <summary>Raw text of the single value line, for every scalar tag except <c>ba</c>.</summary>
|
||||
public string? ScalarText { get; set; }
|
||||
|
||||
/// <summary>The nested table payload, only when <see cref="Tag"/> is <c>TABLE</c>.</summary>
|
||||
public VtankTable? Table { get; set; }
|
||||
|
||||
/// <summary>The raw character blob, only when <see cref="Tag"/> is <c>ba</c>.</summary>
|
||||
public string? BlobText { get; set; }
|
||||
|
||||
public static VtankCell Double(double value) => new()
|
||||
{
|
||||
Tag = "d",
|
||||
ScalarText = FormatDouble(value),
|
||||
};
|
||||
|
||||
public static VtankCell Int(int value) => new()
|
||||
{
|
||||
Tag = "i",
|
||||
ScalarText = value.ToString(CultureInfo.InvariantCulture),
|
||||
};
|
||||
|
||||
public static VtankCell UInt(uint value) => new()
|
||||
{
|
||||
Tag = "u",
|
||||
ScalarText = value.ToString(CultureInfo.InvariantCulture),
|
||||
};
|
||||
|
||||
public static VtankCell Float(float value) => new()
|
||||
{
|
||||
Tag = "f",
|
||||
ScalarText = FormatDouble(value),
|
||||
};
|
||||
|
||||
public static VtankCell String(string value) => new()
|
||||
{
|
||||
Tag = "s",
|
||||
// VTank strips embedded newlines from a string cell on write
|
||||
// (gy.cs:84: `text.Replace("\n", "")`) — a string cell is always
|
||||
// exactly one line; a blob that needs embedded newlines uses "ba".
|
||||
ScalarText = value.Replace("\n", string.Empty, StringComparison.Ordinal),
|
||||
};
|
||||
|
||||
public static VtankCell Bool(bool value) => new()
|
||||
{
|
||||
Tag = "b",
|
||||
ScalarText = value ? "True" : "False",
|
||||
};
|
||||
|
||||
public static VtankCell NestedTable(VtankTable table) => new()
|
||||
{
|
||||
Tag = "TABLE",
|
||||
Table = table,
|
||||
};
|
||||
|
||||
public double AsDouble() => double.Parse(
|
||||
ScalarText ?? "0",
|
||||
NumberStyles.Float,
|
||||
CultureInfo.InvariantCulture);
|
||||
|
||||
public int AsInt() => int.Parse(
|
||||
ScalarText ?? "0",
|
||||
NumberStyles.Integer,
|
||||
CultureInfo.InvariantCulture);
|
||||
|
||||
public uint AsUInt() => uint.Parse(
|
||||
ScalarText ?? "0",
|
||||
NumberStyles.Integer,
|
||||
CultureInfo.InvariantCulture);
|
||||
|
||||
public float AsFloat() => float.Parse(
|
||||
ScalarText ?? "0",
|
||||
NumberStyles.Float,
|
||||
CultureInfo.InvariantCulture);
|
||||
|
||||
public string AsString() => ScalarText ?? string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// VTank's <c>gy</c> bool tag stores the literal text <c>"True"</c> or
|
||||
/// <c>"False"</c> (C#'s default <see cref="bool"/> formatting).
|
||||
/// </summary>
|
||||
public bool AsBool() => string.Equals(ScalarText, "True", StringComparison.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// Formats a double/float the way VTank's own (older .NET Framework)
|
||||
/// build does: <c>Convert.ToString(double, CultureInfo)</c>
|
||||
/// (<c>gy.cs:60-61</c>) used the CLR's classic 15-significant-digit
|
||||
/// general format, not .NET Core's shortest-round-trippable default.
|
||||
/// Verified against every <c>d</c>-tagged value in the real
|
||||
/// <c>defaultsettings.usd</c> fixture (e.g. 5/240 renders as
|
||||
/// <c>0.0208333333333333</c> — 15 significant digits).
|
||||
/// </summary>
|
||||
internal static string FormatDouble(double value) =>
|
||||
value.ToString("G15", CultureInfo.InvariantCulture);
|
||||
|
||||
internal void WriteTo(StringBuilder sb)
|
||||
{
|
||||
VtankWriter.AppendLine(sb, Tag);
|
||||
if (Tag == "TABLE")
|
||||
{
|
||||
Table!.WriteTo(sb);
|
||||
}
|
||||
else if (Tag == "ba")
|
||||
{
|
||||
// A "ba" blob is a raw character count, not a line count: the
|
||||
// blob may contain embedded newlines. VTank's own writer
|
||||
// (f6.cs:20-24) WriteLine()s only the length, then Write()s the
|
||||
// raw characters with NO trailing line terminator — whatever
|
||||
// structural token follows continues immediately after the
|
||||
// blob's last character, exactly like the real client's output.
|
||||
string blob = BlobText ?? string.Empty;
|
||||
VtankWriter.AppendLine(sb, blob.Length.ToString(CultureInfo.InvariantCulture));
|
||||
sb.Append(blob);
|
||||
}
|
||||
else if (KnownScalarTags.Contains(Tag))
|
||||
{
|
||||
VtankWriter.AppendLine(sb, ScalarText ?? string.Empty);
|
||||
}
|
||||
// else: an unrecognized/void tag is the WHOLE cell (gy.cs:98-101
|
||||
// writes only "0", no value line) — nothing further to emit.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Shared line-terminator helper for every <c>WriteTo</c> in this file (CRLF, matching VTank's own writer).</summary>
|
||||
internal static class VtankWriter
|
||||
{
|
||||
internal static void AppendLine(StringBuilder sb, string text) =>
|
||||
sb.Append(text).Append("\r\n");
|
||||
}
|
||||
|
||||
internal sealed class VtankRow
|
||||
{
|
||||
public List<VtankCell> Cells { get; } = [];
|
||||
|
||||
internal void WriteTo(StringBuilder sb)
|
||||
{
|
||||
foreach (VtankCell cell in Cells)
|
||||
cell.WriteTo(sb);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One <c>bd</c> table: a column-name list, a hash-index flag per column,
|
||||
/// and a row list. Row length is NOT fixed at parse time — a <c>TABLE</c> or
|
||||
/// <c>ba</c> cell recurses, so the reader must walk cell-by-cell rather than
|
||||
/// assume a fixed line stride per row (<c>bd.cs:364-395</c>).
|
||||
/// </summary>
|
||||
internal sealed class VtankTable
|
||||
{
|
||||
public List<string> ColumnNames { get; } = [];
|
||||
public List<bool> IndexFlags { get; } = [];
|
||||
public List<VtankRow> Rows { get; } = [];
|
||||
|
||||
public int ColumnIndex(string name) => ColumnNames.FindIndex(
|
||||
column => column.Equals(name, StringComparison.Ordinal));
|
||||
|
||||
internal static VtankTable Read(VtankLineCursor cursor)
|
||||
{
|
||||
var table = new VtankTable();
|
||||
int columnCount = cursor.ReadInt();
|
||||
for (int i = 0; i < columnCount; i++)
|
||||
table.ColumnNames.Add(cursor.ReadLine());
|
||||
for (int i = 0; i < columnCount; i++)
|
||||
table.IndexFlags.Add(cursor.ReadLine() == "y");
|
||||
int rowCount = cursor.ReadInt();
|
||||
for (int r = 0; r < rowCount; r++)
|
||||
{
|
||||
var row = new VtankRow();
|
||||
for (int c = 0; c < columnCount; c++)
|
||||
row.Cells.Add(VtankDatabaseReader.ReadCell(cursor));
|
||||
table.Rows.Add(row);
|
||||
}
|
||||
return table;
|
||||
}
|
||||
|
||||
internal void WriteTo(StringBuilder sb)
|
||||
{
|
||||
VtankWriter.AppendLine(sb, ColumnNames.Count.ToString(CultureInfo.InvariantCulture));
|
||||
foreach (string column in ColumnNames)
|
||||
VtankWriter.AppendLine(sb, column);
|
||||
foreach (bool flag in IndexFlags)
|
||||
VtankWriter.AppendLine(sb, flag ? "y" : "n");
|
||||
VtankWriter.AppendLine(sb, Rows.Count.ToString(CultureInfo.InvariantCulture));
|
||||
foreach (VtankRow row in Rows)
|
||||
row.WriteTo(sb);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The whole <c>y</c> database: an ordered set of named tables.</summary>
|
||||
internal sealed class VtankDatabase
|
||||
{
|
||||
public List<(string Name, VtankTable Table)> Tables { get; } = [];
|
||||
|
||||
public VtankTable? Find(string name) => Tables
|
||||
.Where(entry => entry.Name.Equals(name, StringComparison.Ordinal))
|
||||
.Select(static entry => entry.Table)
|
||||
.FirstOrDefault();
|
||||
|
||||
public static VtankDatabase Parse(string text)
|
||||
{
|
||||
var cursor = new VtankLineCursor(text);
|
||||
var database = new VtankDatabase();
|
||||
int tableCount = cursor.ReadInt();
|
||||
for (int i = 0; i < tableCount; i++)
|
||||
{
|
||||
string name = cursor.ReadLine();
|
||||
VtankTable table = VtankTable.Read(cursor);
|
||||
database.Tables.Add((name, table));
|
||||
}
|
||||
return database;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// VTank's own <c>y</c> class holds its tables in a
|
||||
/// <c>SortedDictionary<string, bd></c> (item J, slice-1 fix
|
||||
/// round), so a real <c>.usd</c>/<c>.ast</c> always emits its tables in
|
||||
/// table-name order — confirmed against the committed
|
||||
/// <c>defaultsettings.usd</c> fixture, whose first several tables
|
||||
/// (AntiExtraBuffSpells, AssistItems, BuffedItems, ExtraBuffSpells,
|
||||
/// GemFoodItems, …) are already alphabetical. This port's own
|
||||
/// <see cref="Tables"/> is an insertion-ordered list rather than a
|
||||
/// sorted structure, so every committed real fixture happens to
|
||||
/// round-trip in order today purely because it was ALREADY sorted the
|
||||
/// last time real VTank wrote it — this explicit sort on render makes
|
||||
/// that invariant a property of the writer itself rather than an
|
||||
/// accident of whatever order the source data happened to arrive in
|
||||
/// (no observable behavior change for any fixture today; it only
|
||||
/// matters the day a future code path adds or reorders a table).
|
||||
///
|
||||
/// Round 3 item 12: <see cref="StringComparer.Ordinal"/> is used here
|
||||
/// as a stand-in for .NET Framework's <c>SortedDictionary<string, bd></c>
|
||||
/// default key order — that equivalence is confirmed only for the
|
||||
/// plain-ASCII, single-case table names VTank itself ships
|
||||
/// (AntiExtraBuffSpells, MyMonsters, Settings, …), not as a general
|
||||
/// claim that ordinal and .NET's culture-aware default string
|
||||
/// comparer agree for every possible string; a hypothetical future
|
||||
/// table name using non-ASCII characters or mixed-width comparisons
|
||||
/// could sort differently under the two. No behavior change is
|
||||
/// implied or made by this note.
|
||||
/// </summary>
|
||||
public string Render()
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
var ordered = Tables
|
||||
.OrderBy(static entry => entry.Name, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
VtankWriter.AppendLine(sb, ordered.Length.ToString(CultureInfo.InvariantCulture));
|
||||
foreach ((string name, VtankTable table) in ordered)
|
||||
{
|
||||
VtankWriter.AppendLine(sb, name);
|
||||
table.WriteTo(sb);
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Reads one <see cref="VtankCell"/>, recursing into nested tables and blobs.</summary>
|
||||
internal static class VtankDatabaseReader
|
||||
{
|
||||
public static VtankCell ReadCell(VtankLineCursor cursor)
|
||||
{
|
||||
string tag = cursor.ReadLine();
|
||||
switch (tag)
|
||||
{
|
||||
case "TABLE":
|
||||
return VtankCell.NestedTable(VtankTable.Read(cursor));
|
||||
case "ba":
|
||||
int length = cursor.ReadInt();
|
||||
return new VtankCellBuilder(tag) { BlobText = cursor.ReadBlob(length) }
|
||||
.Build();
|
||||
case "d" or "i" or "u" or "f" or "s" or "b":
|
||||
return new VtankCellBuilder(tag) { ScalarText = cursor.ReadLine() }.Build();
|
||||
default:
|
||||
// Unrecognized/void tag (refs/vtank/decompiled/y.cs:28-41
|
||||
// only ever registers TABLE and ba as named custom types):
|
||||
// VTank's own reader (gy.cs:50-55) consumes ONLY the tag
|
||||
// line here — no value line follows.
|
||||
return new VtankCellBuilder(tag).Build();
|
||||
}
|
||||
}
|
||||
|
||||
// Small builder so VtankCell's constructor can stay private to the file
|
||||
// (its public factory methods are the intended typed-construction path)
|
||||
// while the reader still needs to synthesize an arbitrary/unknown tag.
|
||||
private readonly struct VtankCellBuilder(string tag)
|
||||
{
|
||||
public string? ScalarText { get; init; }
|
||||
public string? BlobText { get; init; }
|
||||
|
||||
public VtankCell Build() => VtankCellFactory.Create(tag, ScalarText, BlobText);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Internal factory seam so the reader can build cells with an arbitrary tag.</summary>
|
||||
internal static class VtankCellFactory
|
||||
{
|
||||
public static VtankCell Create(string tag, string? scalarText, string? blobText) => new()
|
||||
{
|
||||
Tag = tag,
|
||||
ScalarText = scalarText,
|
||||
BlobText = blobText,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Character-oriented cursor over the exact (unnormalized) text of a
|
||||
/// <c>.usd</c>/<c>.ast</c> file. VTank's own reader is a strict token stream
|
||||
/// (<c>StreamReader.ReadLine()</c>/<c>TextReader.Read(char[], int, int)</c>
|
||||
/// calls in a fixed order) — there is no way to resynchronize after a parse
|
||||
/// error, so every read here throws immediately with a file:line-shaped
|
||||
/// message on the first unexpected token.
|
||||
///
|
||||
/// The cursor deliberately keeps the ORIGINAL string, never pre-splitting on
|
||||
/// <c>'\n'</c>: a <c>ba</c> blob (<see cref="ReadBlob"/>) reads exactly N raw
|
||||
/// characters straight out of that string, embedded <c>\r</c>/<c>\n</c>
|
||||
/// included, matching <c>f6.cs:10-17</c>'s <c>TextReader.Read(array, 0, num)</c>
|
||||
/// exactly — a CRLF-terminated line INSIDE a blob counts as 2 characters
|
||||
/// toward that length, same as it would for VTank itself.
|
||||
/// </summary>
|
||||
internal sealed class VtankLineCursor
|
||||
{
|
||||
private readonly string _text;
|
||||
private int _position;
|
||||
private int _lineNumber = 1;
|
||||
|
||||
public VtankLineCursor(string text)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(text);
|
||||
_text = text;
|
||||
}
|
||||
|
||||
public int LineNumber => _lineNumber;
|
||||
|
||||
public string ReadLine()
|
||||
{
|
||||
if (_position >= _text.Length)
|
||||
{
|
||||
throw new FormatException(
|
||||
$"line {LineNumber}: unexpected end of file (expected another line).");
|
||||
}
|
||||
int newlineIndex = _text.IndexOf('\n', _position);
|
||||
string line;
|
||||
if (newlineIndex < 0)
|
||||
{
|
||||
line = _text[_position..];
|
||||
_position = _text.Length;
|
||||
}
|
||||
else
|
||||
{
|
||||
line = _text[_position..newlineIndex];
|
||||
_position = newlineIndex + 1;
|
||||
}
|
||||
if (line.EndsWith('\r'))
|
||||
line = line[..^1];
|
||||
_lineNumber++;
|
||||
return line;
|
||||
}
|
||||
|
||||
public int ReadInt()
|
||||
{
|
||||
string line = ReadLine();
|
||||
if (!int.TryParse(line, NumberStyles.Integer, CultureInfo.InvariantCulture, out int value))
|
||||
{
|
||||
throw new FormatException(
|
||||
$"line {LineNumber - 1}: expected an integer, got '{line}'.");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads a <c>ba</c> blob's exact character count straight out of the
|
||||
/// underlying text with no line-oriented interpretation whatsoever
|
||||
/// (<c>f6.cs:10-17</c>: <c>TextReader.Read(array, 0, num)</c> starting
|
||||
/// immediately after the length line's terminator) — embedded
|
||||
/// <c>\r</c>/<c>\n</c> characters are counted and returned verbatim, not
|
||||
/// folded or re-joined.
|
||||
/// </summary>
|
||||
public string ReadBlob(int length)
|
||||
{
|
||||
if (length <= 0)
|
||||
return string.Empty;
|
||||
if (_position + length > _text.Length)
|
||||
{
|
||||
throw new FormatException(
|
||||
$"line {LineNumber}: blob of {length} characters exceeds remaining input.");
|
||||
}
|
||||
string blob = _text.Substring(_position, length);
|
||||
_position += length;
|
||||
foreach (char c in blob)
|
||||
{
|
||||
if (c == '\n')
|
||||
_lineNumber++;
|
||||
}
|
||||
return blob;
|
||||
}
|
||||
}
|
||||
|
|
@ -136,6 +136,9 @@
|
|||
onclick="{CreateProfile}" />
|
||||
<button x="448" y="36" w="112" h="23" text="Clear profile!"
|
||||
onclick="{ClearProfile}" />
|
||||
<button x="568" y="36" w="76" h="23" text="Delete"
|
||||
onclick="{DeleteProfile}"
|
||||
tooltip="Delete the selected named profile's real .usd file. Not available for By char." />
|
||||
|
||||
<toggle x="580" y="4" w="120" h="20" text="Mine only"
|
||||
checked="{MineOnlyEnabled}" onclick="{ToggleMineOnly}" />
|
||||
|
|
@ -155,6 +158,9 @@
|
|||
onclick="{CreateRouteProfile}" />
|
||||
<button x="560" y="64" w="84" h="22" text="Clear route"
|
||||
onclick="{ClearRouteProfile}" />
|
||||
<button x="650" y="64" w="70" h="22" text="Delete"
|
||||
onclick="{DeleteRouteProfile}"
|
||||
tooltip="Delete the selected named route's real .af file. Not available for By char." />
|
||||
<toggle x="4" y="92" w="150" h="20" text="Enable Looting"
|
||||
checked="{LootEnabled}" onclick="{ToggleLooting}" />
|
||||
<menu x="156" y="90" w="116" h="22" items="{LootProfileNames}"
|
||||
|
|
@ -454,9 +460,12 @@
|
|||
onclick="{CopyMetaProfile}" />
|
||||
<button x="378" y="0" w="48" h="21" text="Clear"
|
||||
onclick="{ClearMetaProfile}" />
|
||||
<toggle x="446" y="2" w="112" h="20" text="Enable Meta"
|
||||
<button x="428" y="0" w="66" h="21" text="Delete"
|
||||
onclick="{DeleteMetaProfile}"
|
||||
tooltip="Delete the selected named Meta profile's real .af file. Not available for By char." />
|
||||
<toggle x="500" y="2" w="112" h="20" text="Enable Meta"
|
||||
checked="{MetaEnabled}" onclick="{ToggleMeta}" />
|
||||
<label x="566" y="4" text="{MetaStateText}" color="#FFE8DEC3" />
|
||||
<label x="620" y="4" text="{MetaStateText}" color="#FFE8DEC3" />
|
||||
|
||||
<list x="4" y="26" w="776" h="68" rowheight="17"
|
||||
items="{MetaRows}" selected="{SelectedMetaRuleIndex}"
|
||||
|
|
@ -530,6 +539,9 @@
|
|||
onclick="{CopyLootProfile}" />
|
||||
<button x="494" y="0" w="58" h="21" text="Clear"
|
||||
onclick="{ClearLootProfile}" />
|
||||
<button x="560" y="0" w="64" h="21" text="Delete"
|
||||
onclick="{DeleteLootProfile}"
|
||||
tooltip="Delete the selected named loot profile's real .utl file. Not available for By char." />
|
||||
<button x="684" y="0" w="92" h="21" text="Back"
|
||||
onclick="{CloseLootEditor}" />
|
||||
<list x="4" y="24" w="520" h="82" rowheight="17"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue