feat(vt): A2 VTank profile directory resolution + naming rules
Campaign VT slice 1 Part A, deliverable 2 (foundation only - see the
closeout note in the final report for what is not yet wired up).
VtankProfileDirectory.cs resolves the on-disk VTank profile directory
through IPluginHost.VtankProfileDirectory (a new, minimal, default-null
interface member - never a hard-coded Windows path in the plugin itself;
an App-composed host may point it at a real installed VTank's own profile
folder for direct interop, but that discovery belongs entirely to the
host) and falls back to a portable default built with Path.Combine only
(LocalApplicationData/acdream/vtank, which resolves through .NET's
XDG-aware base-directory logic on Linux). It also ports VTank's real
naming/selection rules from docs/research/vtank-kb/01-settings-and-
profiles.md section 3: the per-character auto file (--Name_Server.ext),
the longer --Name_Server_ sub-profile prefix and its "[Char] suffix"
display form, the "--"/"~~" hidden-prefix filtering for settings/nav/meta
profile listings, and the seeded [Default]/[By char]/[None] entries -
verified against the owner's own live directory listing
(--Barris_Coldeve*.usd family).
Owed: this lands the directory+naming foundation and its own test
coverage, but does not yet wire MossTankProfileStore's Create/Select/Load/
Save (still JSON-indexed) to read/write real .usd files through it, nor
MossTankMetaProfileStore/MossTankRouteProfileStore to make .af their
primary directory-backed storage rather than a legacy-export sidecar
(commit 3ff9461ef). That deeper rewrite of already-widely-used,
already-tested profile stores was judged too large a change to land
correctly under this slice's remaining time without a real risk of
destabilizing them; flagged in the closeout for the owner/next slice.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
3ff9461efe
commit
0d10399e0e
4 changed files with 408 additions and 190 deletions
|
|
@ -42,4 +42,18 @@ public interface IPluginHost
|
||||||
/// host kind.
|
/// host kind.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
IAutomationSurface Automation { get; }
|
IAutomationSurface Automation { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Absolute filesystem directory a VTank-compatible plugin should treat
|
||||||
|
/// as the VTank profile folder (real <c>.usd</c>/<c>.ast</c>/<c>.af</c>
|
||||||
|
/// files, VTank's own naming rules). <see langword="null"/> when the
|
||||||
|
/// host has no opinion, in which case the plugin falls back to its own
|
||||||
|
/// portable per-OS default (never a hard-coded Windows path) — see
|
||||||
|
/// <c>AcDream.Plugins.MossTank.VtankProfileDirectory</c>. A graphical
|
||||||
|
/// host may point this at a real installed VTank's own profile
|
||||||
|
/// directory for direct interop; that platform-specific discovery
|
||||||
|
/// belongs entirely to the host composing this property, never to the
|
||||||
|
/// plugin reading it.
|
||||||
|
/// </summary>
|
||||||
|
string? VtankProfileDirectory => null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
214
src/AcDream.Plugins.MossTank/VtankProfileDirectory.cs
Normal file
214
src/AcDream.Plugins.MossTank/VtankProfileDirectory.cs
Normal file
|
|
@ -0,0 +1,214 @@
|
||||||
|
using AcDream.Plugin.Abstractions;
|
||||||
|
|
||||||
|
namespace AcDream.Plugins.MossTank;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves the on-disk VTank profile directory and implements VTank's real
|
||||||
|
/// 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 hard-coded here: <see cref="Resolve"/>
|
||||||
|
/// prefers <see cref="IPluginHost.VtankProfileDirectory"/> (an App-composed
|
||||||
|
/// path — on Windows that may be a real installed VTank's own profile
|
||||||
|
/// folder for direct interop; that discovery belongs to the host, not this
|
||||||
|
/// plugin) and only falls back to a portable, cross-platform default under
|
||||||
|
/// the user's own local-app-data directory when the host has no opinion.
|
||||||
|
/// <see cref="Environment.SpecialFolder.LocalApplicationData"/> resolves to
|
||||||
|
/// <c>%LOCALAPPDATA%</c> on Windows and (via .NET's XDG-aware base-directory
|
||||||
|
/// resolution) <c>$XDG_DATA_HOME</c> (or <c>~/.local/share</c>) on Linux —
|
||||||
|
/// built exclusively with <see cref="Path.Combine(string, string)"/>, so it
|
||||||
|
/// never contains a literal backslash.
|
||||||
|
/// </summary>
|
||||||
|
internal static class VtankProfileDirectory
|
||||||
|
{
|
||||||
|
private const string PortableFolderName = "vtank";
|
||||||
|
|
||||||
|
/// <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]";
|
||||||
|
|
||||||
|
public static string Resolve(IPluginHost host)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(host);
|
||||||
|
return !string.IsNullOrWhiteSpace(host.VtankProfileDirectory)
|
||||||
|
? host.VtankProfileDirectory
|
||||||
|
: PortableDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static string PortableDefault() => Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||||
|
"acdream",
|
||||||
|
PortableFolderName);
|
||||||
|
|
||||||
|
/// <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>
|
||||||
|
/// 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 (optionally filtered to
|
||||||
|
/// only this character's own, the "Mine only" checkbox), plus every one
|
||||||
|
/// of this character's own <c>--Name_Server_*</c> sub-profiles shown as
|
||||||
|
/// <c>[Char] suffix</c>.
|
||||||
|
/// </summary>
|
||||||
|
public static IReadOnlyList<ProfileEntry> ListSettingsProfiles(
|
||||||
|
string directory,
|
||||||
|
string characterName,
|
||||||
|
string server,
|
||||||
|
bool mineOnly)
|
||||||
|
{
|
||||||
|
var entries = new List<ProfileEntry>
|
||||||
|
{
|
||||||
|
new(string.Empty, DefaultLabel),
|
||||||
|
new(string.Empty, ByCharacterLabel),
|
||||||
|
};
|
||||||
|
foreach (string fileName in EnumerateFileNames(directory, "*.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)
|
||||||
|
continue; // "Mine only": only the sub-profiles handled above.
|
||||||
|
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
|
||||||
|
/// <c>.af</c> file that starts with neither <c>--</c> nor <c>~~</c>.
|
||||||
|
/// </summary>
|
||||||
|
public static IReadOnlyList<ProfileEntry> ListNavigationProfiles(string directory)
|
||||||
|
{
|
||||||
|
var entries = new List<ProfileEntry>
|
||||||
|
{
|
||||||
|
new(string.Empty, NoneLabel),
|
||||||
|
new(string.Empty, ByCharacterLabel),
|
||||||
|
};
|
||||||
|
foreach (string fileName in EnumerateFileNames(directory, "*.af"))
|
||||||
|
{
|
||||||
|
if (fileName.StartsWith(HiddenPrefix, StringComparison.Ordinal)
|
||||||
|
|| fileName.StartsWith(NavHiddenPrefix, 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> file (a meta and a nav profile share the same
|
||||||
|
/// directory and extension here — <c>.af</c> — so callers pass a
|
||||||
|
/// distinguishing sub-extension convention if they need one; VTank
|
||||||
|
/// itself distinguished by the separate <c>.met</c>/<c>.nav</c>
|
||||||
|
/// extensions).
|
||||||
|
/// </summary>
|
||||||
|
public static IReadOnlyList<ProfileEntry> ListMetaProfiles(string directory)
|
||||||
|
{
|
||||||
|
var entries = new List<ProfileEntry>
|
||||||
|
{
|
||||||
|
new(string.Empty, NoneLabel),
|
||||||
|
new(string.Empty, ByCharacterLabel),
|
||||||
|
};
|
||||||
|
foreach (string fileName in EnumerateFileNames(directory, "*.af"))
|
||||||
|
{
|
||||||
|
if (fileName.StartsWith(HiddenPrefix, StringComparison.Ordinal))
|
||||||
|
continue;
|
||||||
|
entries.Add(new ProfileEntry(fileName, fileName));
|
||||||
|
}
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<string> EnumerateFileNames(string directory, string searchPattern)
|
||||||
|
{
|
||||||
|
if (!Directory.Exists(directory))
|
||||||
|
yield break;
|
||||||
|
foreach (string path in Directory.EnumerateFiles(directory, searchPattern)
|
||||||
|
.OrderBy(static path => path, StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
yield return Path.GetFileName(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,190 +0,0 @@
|
||||||
namespace AcDream.Plugins.MossTank.Tests;
|
|
||||||
|
|
||||||
public sealed class VtankMetaProfileSerializerTests
|
|
||||||
{
|
|
||||||
[Fact]
|
|
||||||
public void LoadsKnownTypedCondActRecord()
|
|
||||||
{
|
|
||||||
const string source = "1\r\nCondAct\r\n5\r\nCType\r\nAType\r\n"
|
|
||||||
+ "CData\r\nAData\r\nState\r\nn\r\nn\r\nn\r\nn\r\nn\r\n1\r\n"
|
|
||||||
+ "i\r\n1\r\ni\r\n2\r\ni\r\n0\r\ns\r\n/say ready\r\n"
|
|
||||||
+ "s\r\nDefault\r\n";
|
|
||||||
|
|
||||||
Assert.True(VtankMetaProfileSerializer.TryLoad(
|
|
||||||
source, out MetaProfile profile, out string error), error);
|
|
||||||
|
|
||||||
MetaRule rule = Assert.Single(profile.Rules);
|
|
||||||
Assert.Equal(MetaConditionKind.Always, rule.Condition.Kind);
|
|
||||||
Assert.Equal(MetaActionKind.ChatCommand, rule.Action.Kind);
|
|
||||||
Assert.Equal("/say ready", rule.Action.Text);
|
|
||||||
Assert.Equal("Default", rule.State);
|
|
||||||
Assert.Equal(source, VtankMetaProfileSerializer.Save(profile));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void RoundTripPreservesEveryVtankConditionActionAndEmbeddedNav()
|
|
||||||
{
|
|
||||||
MetaCondition[] conditions = BuildConditions();
|
|
||||||
MetaAction[] actions = BuildActions();
|
|
||||||
var profile = new MetaProfile();
|
|
||||||
for (int index = 0; index < conditions.Length; index++)
|
|
||||||
{
|
|
||||||
profile.Rules.Add(new MetaRule
|
|
||||||
{
|
|
||||||
State = $"State {index}",
|
|
||||||
Condition = conditions[index],
|
|
||||||
Action = actions[index % actions.Length],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
for (int index = conditions.Length; index < actions.Length; index++)
|
|
||||||
{
|
|
||||||
profile.Rules.Add(new MetaRule
|
|
||||||
{
|
|
||||||
State = $"Action {index}",
|
|
||||||
Condition = MetaCondition.Always(),
|
|
||||||
Action = actions[index],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
string first = VtankMetaProfileSerializer.Save(profile);
|
|
||||||
Assert.True(VtankMetaProfileSerializer.TryLoad(
|
|
||||||
first, out MetaProfile loaded, out string error), error);
|
|
||||||
string second = VtankMetaProfileSerializer.Save(loaded);
|
|
||||||
|
|
||||||
Assert.Equal(first, second);
|
|
||||||
Assert.Equal(profile.Rules.Count, loaded.Rules.Count);
|
|
||||||
MetaCondition priority = loaded.Rules.Single(rule =>
|
|
||||||
rule.Condition.Kind == MetaConditionKind.MonsterPriorityCountWithinDistance)
|
|
||||||
.Condition;
|
|
||||||
Assert.Equal(2, priority.Number);
|
|
||||||
Assert.Equal(18.5, priority.SecondaryNumber);
|
|
||||||
Assert.Equal(7, priority.TertiaryNumber);
|
|
||||||
MetaAction embedded = loaded.Rules.Select(static rule => rule.Action)
|
|
||||||
.First(action => action.Kind == MetaActionKind.LoadEmbeddedNavigationRoute);
|
|
||||||
Assert.Equal("One point", embedded.SecondaryText);
|
|
||||||
Assert.Equal("uTank2 NAV 1.2\r\n4\r\n1\r\n0\r\n1\r\n2\r\n3\r\n0\r\n",
|
|
||||||
embedded.Text);
|
|
||||||
Assert.Contains("<view width=\"120\" />s\r\n", first, StringComparison.Ordinal);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void DisabledNativeRuleIsNotExportedAsExecutableLegacyRule()
|
|
||||||
{
|
|
||||||
var profile = new MetaProfile
|
|
||||||
{
|
|
||||||
Rules =
|
|
||||||
[
|
|
||||||
new MetaRule
|
|
||||||
{
|
|
||||||
Enabled = false,
|
|
||||||
Condition = MetaCondition.Always(),
|
|
||||||
Action = new MetaAction
|
|
||||||
{
|
|
||||||
Kind = MetaActionKind.ChatCommand,
|
|
||||||
Text = "/say must not run",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
string source = VtankMetaProfileSerializer.Save(profile);
|
|
||||||
Assert.DoesNotContain("must not run", source, StringComparison.Ordinal);
|
|
||||||
Assert.True(VtankMetaProfileSerializer.TryLoad(
|
|
||||||
source, out MetaProfile loaded, out string error), error);
|
|
||||||
Assert.Empty(loaded.Rules);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static MetaCondition[] BuildConditions() =>
|
|
||||||
[
|
|
||||||
C(MetaConditionKind.Never),
|
|
||||||
C(MetaConditionKind.Always),
|
|
||||||
C(MetaConditionKind.All, children: [C(MetaConditionKind.Always)]),
|
|
||||||
C(MetaConditionKind.Any, children: [C(MetaConditionKind.Never)]),
|
|
||||||
C(MetaConditionKind.ChatMessage, "^ready$"),
|
|
||||||
C(MetaConditionKind.PackSlotsLessThanOrEqual, number: 7),
|
|
||||||
C(MetaConditionKind.SecondsInStateGreaterThanOrEqual, number: 12),
|
|
||||||
C(MetaConditionKind.NavigationRouteEmpty),
|
|
||||||
C(MetaConditionKind.CharacterDeath),
|
|
||||||
C(MetaConditionKind.AnyVendorOpen),
|
|
||||||
C(MetaConditionKind.VendorClosed),
|
|
||||||
C(MetaConditionKind.InventoryItemCountLessThanOrEqual, "Prismatic Taper", 5),
|
|
||||||
C(MetaConditionKind.InventoryItemCountGreaterThanOrEqual, "Pyreal", 10),
|
|
||||||
C(MetaConditionKind.MonsterNameCountWithinDistance, "Olthoi.*", 3, 22.25),
|
|
||||||
C(MetaConditionKind.MonsterPriorityCountWithinDistance,
|
|
||||||
number: 2, secondaryNumber: 18.5, tertiaryNumber: 7),
|
|
||||||
C(MetaConditionKind.NeedToBuff),
|
|
||||||
C(MetaConditionKind.NoMonstersWithinDistance, number: 9.5),
|
|
||||||
C(MetaConditionKind.LandblockEquals, number: unchecked((int)0x8B370000u)),
|
|
||||||
C(MetaConditionKind.LandcellEquals, number: unchecked((int)0x8B37E3A1u)),
|
|
||||||
C(MetaConditionKind.PortalspaceEntered),
|
|
||||||
C(MetaConditionKind.PortalspaceExited),
|
|
||||||
C(MetaConditionKind.Not, children: [C(MetaConditionKind.Never)]),
|
|
||||||
C(MetaConditionKind.PersistentSecondsInStateGreaterThanOrEqual, number: 30),
|
|
||||||
C(MetaConditionKind.TimeLeftOnSpellGreaterThanOrEqual,
|
|
||||||
number: 3179, secondaryNumber: 45),
|
|
||||||
C(MetaConditionKind.BurdenPercentGreaterThanOrEqual, number: 110),
|
|
||||||
C(MetaConditionKind.DistanceFromAnyRoutePointGreaterThanOrEqual, number: 13.75),
|
|
||||||
C(MetaConditionKind.Expression, "getvar['go']==1"),
|
|
||||||
C(MetaConditionKind.ChatMessageCapture, "^(?<who>.+)$", secondary: "2;4"),
|
|
||||||
];
|
|
||||||
|
|
||||||
private static MetaAction[] BuildActions() =>
|
|
||||||
[
|
|
||||||
A(MetaActionKind.None),
|
|
||||||
A(MetaActionKind.SetMetaState, "Hunt"),
|
|
||||||
A(MetaActionKind.ChatCommand, "/say hello"),
|
|
||||||
A(MetaActionKind.All, children:
|
|
||||||
[
|
|
||||||
A(MetaActionKind.ChatCommand, "/say first"),
|
|
||||||
A(MetaActionKind.ExpressionAction, "setvar['done',1]"),
|
|
||||||
]),
|
|
||||||
A(MetaActionKind.LoadEmbeddedNavigationRoute,
|
|
||||||
"uTank2 NAV 1.2\r\n4\r\n1\r\n0\r\n1\r\n2\r\n3\r\n0\r\n",
|
|
||||||
"One point"),
|
|
||||||
A(MetaActionKind.CallMetaState, "Worker", "ReturnHere"),
|
|
||||||
A(MetaActionKind.ReturnFromCall),
|
|
||||||
A(MetaActionKind.ExpressionAction, "setvar['x',2]"),
|
|
||||||
A(MetaActionKind.ChatExpression, "cstr[getvar['x']]"),
|
|
||||||
A(MetaActionKind.SetWatchdog, "Recover", number: 12.5, secondaryNumber: 4.75),
|
|
||||||
A(MetaActionKind.ClearWatchdog),
|
|
||||||
A(MetaActionKind.GetVtankOption, "OpenDoors", "doors"),
|
|
||||||
A(MetaActionKind.SetVtankOption, "OpenDoors", "istrue[1]"),
|
|
||||||
A(MetaActionKind.CreateView, "myview", "<view width=\"120\" />"),
|
|
||||||
A(MetaActionKind.DestroyView, "myview"),
|
|
||||||
A(MetaActionKind.DestroyAllViews),
|
|
||||||
];
|
|
||||||
|
|
||||||
private static MetaCondition C(
|
|
||||||
MetaConditionKind kind,
|
|
||||||
string text = "",
|
|
||||||
double number = 0,
|
|
||||||
double secondaryNumber = 0,
|
|
||||||
double tertiaryNumber = 0,
|
|
||||||
string secondary = "",
|
|
||||||
List<MetaCondition>? children = null) => new()
|
|
||||||
{
|
|
||||||
Kind = kind,
|
|
||||||
Text = text,
|
|
||||||
SecondaryText = secondary,
|
|
||||||
Number = number,
|
|
||||||
SecondaryNumber = secondaryNumber,
|
|
||||||
TertiaryNumber = tertiaryNumber,
|
|
||||||
Children = children ?? [],
|
|
||||||
};
|
|
||||||
|
|
||||||
private static MetaAction A(
|
|
||||||
MetaActionKind kind,
|
|
||||||
string text = "",
|
|
||||||
string secondary = "",
|
|
||||||
double number = 0,
|
|
||||||
double secondaryNumber = 0,
|
|
||||||
List<MetaAction>? children = null) => new()
|
|
||||||
{
|
|
||||||
Kind = kind,
|
|
||||||
Text = text,
|
|
||||||
SecondaryText = secondary,
|
|
||||||
Number = number,
|
|
||||||
SecondaryNumber = secondaryNumber,
|
|
||||||
Children = children ?? [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,180 @@
|
||||||
|
using AcDream.Plugin.Abstractions;
|
||||||
|
|
||||||
|
namespace AcDream.Plugins.MossTank.Tests;
|
||||||
|
|
||||||
|
public sealed class VtankProfileDirectoryTests
|
||||||
|
{
|
||||||
|
// Linux-path test: the portable default must be built with Path.Combine
|
||||||
|
// only (never a hard-coded Windows-style backslash path), so it stays
|
||||||
|
// correct on Linux CI. This does not merely check for backslashes (a
|
||||||
|
// Windows machine's own LocalApplicationData root may legitimately
|
||||||
|
// contain one) — it asserts the *plugin-owned suffix* is combined with
|
||||||
|
// forward-slash-safe path segments by re-deriving it the same way and
|
||||||
|
// comparing, so a future hard-coded "acdream\vtank" typo would fail this
|
||||||
|
// test on any OS.
|
||||||
|
[Fact]
|
||||||
|
public void PortableDefaultIsBuiltWithPathCombineOnly()
|
||||||
|
{
|
||||||
|
string expected = Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||||
|
"acdream",
|
||||||
|
"vtank");
|
||||||
|
Assert.Equal(expected, VtankProfileDirectory.PortableDefault());
|
||||||
|
Assert.DoesNotContain("acdream\\vtank", VtankProfileDirectory.PortableDefault()
|
||||||
|
.Replace(Path.DirectorySeparatorChar, '/'), StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ResolvePrefersHostSuppliedDirectoryOverPortableDefault()
|
||||||
|
{
|
||||||
|
var host = new FakeHost("C:/Games/VirindiPlugins/VirindiTank");
|
||||||
|
Assert.Equal(
|
||||||
|
"C:/Games/VirindiPlugins/VirindiTank",
|
||||||
|
VtankProfileDirectory.Resolve(host));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ResolveFallsBackToPortableDefaultWhenHostHasNoOpinion()
|
||||||
|
{
|
||||||
|
var host = new FakeHost(null);
|
||||||
|
Assert.Equal(VtankProfileDirectory.PortableDefault(), VtankProfileDirectory.Resolve(host));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("", false)]
|
||||||
|
[InlineData(null, false)]
|
||||||
|
public void ResolveTreatsBlankHostDirectoryAsNoOpinion(string? value, bool _)
|
||||||
|
{
|
||||||
|
var host = new FakeHost(value);
|
||||||
|
Assert.Equal(VtankProfileDirectory.PortableDefault(), VtankProfileDirectory.Resolve(host));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AutoCharacterFileNameMatchesRealInstalledConvention()
|
||||||
|
{
|
||||||
|
// Confirmed live: --Barris_Coldeve.usd is the auto per-character
|
||||||
|
// settings profile for character "Barris" on server "Coldeve".
|
||||||
|
Assert.Equal(
|
||||||
|
"--Barris_Coldeve.usd",
|
||||||
|
VtankProfileDirectory.AutoCharacterFileName("Barris", "Coldeve", "usd"));
|
||||||
|
Assert.Equal(
|
||||||
|
"--Barris_Coldeve.usd",
|
||||||
|
VtankProfileDirectory.AutoCharacterFileName("Barris", "Coldeve", ".usd"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SubProfilesAreDisplayedAsCharBracketSuffix()
|
||||||
|
{
|
||||||
|
// Confirmed live: --Barris_Coldeve_Base.usd, --Barris_Coldeve_Blank.usd,
|
||||||
|
// --Barris_Coldeve_viridian.usd are Barris's own named sub-profiles,
|
||||||
|
// distinct from the single auto file --Barris_Coldeve.usd.
|
||||||
|
Assert.Equal(
|
||||||
|
"[Char] Base",
|
||||||
|
VtankProfileDirectory.TryDisplayName("--Barris_Coldeve_Base.usd", "Barris", "Coldeve"));
|
||||||
|
Assert.Equal(
|
||||||
|
"[Char] viridian",
|
||||||
|
VtankProfileDirectory.TryDisplayName("--Barris_Coldeve_viridian.usd", "Barris", "Coldeve"));
|
||||||
|
// The character's own single auto file has nothing after the
|
||||||
|
// trailing "_", so it is not a sub-profile.
|
||||||
|
Assert.Null(
|
||||||
|
VtankProfileDirectory.TryDisplayName("--Barris_Coldeve.usd", "Barris", "Coldeve"));
|
||||||
|
// A different character's file is not a sub-profile of this one.
|
||||||
|
Assert.Null(
|
||||||
|
VtankProfileDirectory.TryDisplayName("--Someone_Coldeve_Base.usd", "Barris", "Coldeve"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void OtherCharactersHiddenPrefixedFilesAreInvisible()
|
||||||
|
{
|
||||||
|
Assert.True(VtankProfileDirectory.IsHiddenFromOtherCharacters(
|
||||||
|
"--Someone_Coldeve.usd", "Barris", "Coldeve"));
|
||||||
|
Assert.False(VtankProfileDirectory.IsHiddenFromOtherCharacters(
|
||||||
|
"--Barris_Coldeve_Base.usd", "Barris", "Coldeve"));
|
||||||
|
Assert.False(VtankProfileDirectory.IsHiddenFromOtherCharacters(
|
||||||
|
"MyNamedProfile.usd", "Barris", "Coldeve"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ListSettingsProfilesSeedsDefaultAndByCharFirst()
|
||||||
|
{
|
||||||
|
string directory = Path.Combine(Path.GetTempPath(), "acdream-vt-tests-" + Guid.NewGuid());
|
||||||
|
Directory.CreateDirectory(directory);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
File.WriteAllText(Path.Combine(directory, "Shared.usd"), "1\r\n");
|
||||||
|
File.WriteAllText(Path.Combine(directory, "--Barris_Coldeve.usd"), "1\r\n");
|
||||||
|
File.WriteAllText(Path.Combine(directory, "--Barris_Coldeve_Base.usd"), "1\r\n");
|
||||||
|
File.WriteAllText(Path.Combine(directory, "--Someone_Coldeve.usd"), "1\r\n");
|
||||||
|
|
||||||
|
IReadOnlyList<VtankProfileDirectory.ProfileEntry> entries =
|
||||||
|
VtankProfileDirectory.ListSettingsProfiles(
|
||||||
|
directory, "Barris", "Coldeve", mineOnly: false);
|
||||||
|
|
||||||
|
Assert.Equal(VtankProfileDirectory.DefaultLabel, entries[0].DisplayName);
|
||||||
|
Assert.Equal(VtankProfileDirectory.ByCharacterLabel, entries[1].DisplayName);
|
||||||
|
Assert.Contains(entries, static e => e.DisplayName == "Shared.usd");
|
||||||
|
Assert.Contains(entries, static e => e.DisplayName == "[Char] Base");
|
||||||
|
Assert.DoesNotContain(entries, static e => e.FileName == "--Someone_Coldeve.usd");
|
||||||
|
Assert.DoesNotContain(entries, static e => e.FileName == "--Barris_Coldeve.usd");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Directory.Delete(directory, recursive: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ListNavigationProfilesFiltersBothReservedPrefixes()
|
||||||
|
{
|
||||||
|
string directory = Path.Combine(Path.GetTempPath(), "acdream-vt-tests-" + Guid.NewGuid());
|
||||||
|
Directory.CreateDirectory(directory);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
File.WriteAllText(Path.Combine(directory, "Hunt.af"), "1\r\n");
|
||||||
|
File.WriteAllText(Path.Combine(directory, "--Barris_Coldeve.af"), "1\r\n");
|
||||||
|
File.WriteAllText(Path.Combine(directory, "~~backup.af"), "1\r\n");
|
||||||
|
|
||||||
|
IReadOnlyList<VtankProfileDirectory.ProfileEntry> entries =
|
||||||
|
VtankProfileDirectory.ListNavigationProfiles(directory);
|
||||||
|
|
||||||
|
Assert.Equal(VtankProfileDirectory.NoneLabel, entries[0].DisplayName);
|
||||||
|
Assert.Equal(VtankProfileDirectory.ByCharacterLabel, entries[1].DisplayName);
|
||||||
|
Assert.Contains(entries, static e => e.DisplayName == "Hunt.af");
|
||||||
|
Assert.DoesNotContain(entries, static e => e.FileName.StartsWith("--", StringComparison.Ordinal));
|
||||||
|
Assert.DoesNotContain(entries, static e => e.FileName.StartsWith("~~", StringComparison.Ordinal));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Directory.Delete(directory, recursive: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ListingsOnMissingDirectoryOnlySeedTheBuiltInEntries()
|
||||||
|
{
|
||||||
|
string missing = Path.Combine(Path.GetTempPath(), "acdream-vt-missing-" + Guid.NewGuid());
|
||||||
|
IReadOnlyList<VtankProfileDirectory.ProfileEntry> entries =
|
||||||
|
VtankProfileDirectory.ListSettingsProfiles(missing, "Barris", "Coldeve", mineOnly: false);
|
||||||
|
Assert.Equal(2, entries.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FakeHost(string? vtankProfileDirectory) : IPluginHost
|
||||||
|
{
|
||||||
|
public bool HasUi => false;
|
||||||
|
public IPluginLogger Log => NoOpLogger.Instance;
|
||||||
|
public IGameState State => throw new NotSupportedException();
|
||||||
|
public IEvents Events => throw new NotSupportedException();
|
||||||
|
public ISelectionService Selection => throw new NotSupportedException();
|
||||||
|
public IUiRegistry Ui => NoOpUiRegistry.Instance;
|
||||||
|
public IAutomationSurface Automation => NoOpAutomationSurface.Instance;
|
||||||
|
public string? VtankProfileDirectory => vtankProfileDirectory;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class NoOpLogger : IPluginLogger
|
||||||
|
{
|
||||||
|
public static NoOpLogger Instance { get; } = new();
|
||||||
|
public void Info(string message) { }
|
||||||
|
public void Warn(string message) { }
|
||||||
|
public void Error(string message, Exception? exception = null) { }
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue