acdream/src/AcDream.Plugins.MossTank/VtankNavRouteSerializer.cs
Erik f58e997b19 fix(vt): round 3 item 12 — cleanup pass (stale refs, silent swallows, comments)
Five small fixes bundled per the round's cleanup item:
- VtankNavRouteSerializer.cs's doc comment cited a "WriteBinaryNavBlob"
  method that no longer exists anywhere in the codebase (MetaEngine's
  embedded-navigation contract moved to the typed MetaAction.EmbeddedRoute
  NavigationSettings, saved/loaded through MetafSerializer.SaveNav/
  TryLoadNav, back at round 2 step B) — corrected to name the real
  mechanism.
- MossTankCommands.cs:274's comment referenced an "exports/nav/" mirror
  directory that stopped existing when route profiles cut over to writing
  their real .af file directly (round 2 steps 2-3) — corrected.
- docs/research/vtank-kb/07-meta-and-expressions.md section 5.2 row 6
  described the pre-cutover "MossTankMetaProfileStore.WriteLegacyExport
  convenience mirror" design; .af is now the SOLE authoritative Meta
  store, so a disabled rule's save refusal now blocks the profile itself
  — the row now says a disabled rule makes the profile file genuinely
  unsaveable, not that a mirror goes stale.
- The two bare `catch (FormatException) { }` blocks that silently dropped
  a corrupt monster-rule expression (one in SideCarDocument.Apply, reached
  from a corrupt side-car; one in LegacyCombatProfileDocument.Apply,
  reached during legacy-JSON migration) now log a warning via the host's
  IPluginLogger, threaded through as an optional parameter from every call
  site.
- VtankDatabase.Render()'s table-sort doc comment now states explicitly
  that StringComparer.Ordinal matching .NET Framework's SortedDictionary
  default order is confirmed only for the plain-ASCII table names VTank
  ships (AntiExtraBuffSpells, MyMonsters, Settings, …), not as a general
  claim for any string — comment only, no behavior change.

Added CorruptSideCarMonsterRuleIsLoggedNotSilentlySwallowed (FakeLogger
now captures Warn() calls via a new FakeHost.Logger property) pinning the
swallow-to-log fix.

Mutation: reverted MossTankProfileStore.cs to HEAD (keeping only the new
test) and ran it — failed with an empty Warnings collection, confirming
the silent-swallow bug before the fix.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 02:09:22 +02:00

237 lines
9.6 KiB
C#

using System.Globalization;
using AcDream.Plugin.Abstractions;
namespace AcDream.Plugins.MossTank;
/// <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 bool TryLoad(
string source,
NavigationSettings target,
ISpellCatalog spells,
out string error)
{
ArgumentNullException.ThrowIfNull(target);
ArgumentNullException.ThrowIfNull(spells);
try
{
string nav = UnwrapEmbedded(source);
using var reader = new StringReader(nav);
if (!ReadLine(reader).Equals(Header, StringComparison.Ordinal))
throw new FormatException("Nav file version does not match uTank2 NAV 1.2.");
var parsed = new NavigationSettings
{
Enabled = target.Enabled,
Priority = target.Priority,
MinimumDistanceMeters = target.MinimumDistanceMeters,
FollowAroundCorners = target.FollowAroundCorners,
OpenDoors = target.OpenDoors,
Mode = ReadInt(reader) switch
{
1 => RouteMode.Circular,
2 => RouteMode.Linear,
3 => RouteMode.Target,
4 => RouteMode.Once,
_ => throw new FormatException("Unknown VTank navigation type."),
},
};
if (parsed.Mode == RouteMode.Target)
{
parsed.FollowTargetName = ReadLine(reader);
parsed.FollowTargetObjectId = unchecked((uint)ReadInt(reader));
}
else
{
int count = ReadInt(reader);
if (count is < 0 or > 100_000)
throw new FormatException("Invalid VTank waypoint count.");
for (int index = 0; index < count; index++)
parsed.Waypoints.Add(ReadWaypoint(reader, spells));
}
Apply(parsed, target);
error = string.Empty;
return true;
}
catch (Exception exception) when (exception is FormatException
or OverflowException or EndOfStreamException)
{
error = exception.Message;
return false;
}
}
private static RouteWaypoint ReadWaypoint(
TextReader reader,
ISpellCatalog spells)
{
int type = ReadInt(reader);
double eastWest = ReadDouble(reader);
double northSouth = ReadDouble(reader);
double elevation = ReadDouble(reader);
_ = ReadLine(reader); // historical unused coordinate component
var waypoint = new RouteWaypoint
{
Type = type switch
{
0 => RouteWaypointType.Point,
1 => RouteWaypointType.Portal,
2 => RouteWaypointType.Recall,
3 => RouteWaypointType.Pause,
4 => RouteWaypointType.ChatCommand,
5 => RouteWaypointType.OpenVendor,
6 => RouteWaypointType.PortalByName,
7 => RouteWaypointType.UseNpc,
8 => RouteWaypointType.Checkpoint,
9 => RouteWaypointType.Jump,
_ => throw new FormatException($"Unknown VTank waypoint type {type}."),
},
Position = Position(eastWest, northSouth, elevation),
};
switch (type)
{
case 1:
waypoint.ObjectId = unchecked((uint)ReadInt(reader));
break;
case 2:
waypoint.RecallSpellId = checked((uint)ReadInt(reader));
if (spells.TryGet(waypoint.RecallSpellId, out PluginSpellInfo spell))
waypoint.RecallSpellName = spell.Name;
break;
case 3:
waypoint.DurationMilliseconds = ReadInt(reader);
break;
case 4:
waypoint.Text = ReadLine(reader);
break;
case 5:
waypoint.ObjectId = unchecked((uint)ReadInt(reader));
waypoint.ObjectName = ReadLine(reader);
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.ReferencePosition = Position(
referenceEastWest,
referenceNorthSouth,
referenceElevation);
break;
case 9:
waypoint.JumpHeadingDegrees = checked((float)ReadDouble(reader));
waypoint.JumpRun = ReadBoolean(reader);
ParseJump(ReadLine(reader), waypoint);
break;
}
return waypoint;
}
private static void ParseJump(string source, RouteWaypoint target)
{
string value = source.Trim();
char suffix = value.Length == 0 ? '\0' : value[^1];
bool encoded = suffix is '3' or '4' or '5'
&& value.Length >= 6
&& value[^6] == '.';
string milliseconds = encoded ? value[..^1] : value;
target.JumpChargeMilliseconds = checked((int)Math.Round(
double.Parse(milliseconds, NumberStyles.Float, CultureInfo.InvariantCulture),
MidpointRounding.AwayFromZero));
target.JumpDirection = suffix switch
{
'4' when encoded => RouteJumpDirection.StrafeLeft,
'5' when encoded => RouteJumpDirection.StrafeRight,
_ => RouteJumpDirection.Forward,
};
}
private static string UnwrapEmbedded(string source)
{
string normalized = source?.Replace("\r\n", "\n", StringComparison.Ordinal)
?? string.Empty;
if (normalized.StartsWith(Header, StringComparison.Ordinal))
return normalized;
using var reader = new StringReader(normalized);
_ = ReadLine(reader); // embedded route display name
_ = ReadInt(reader); // embedded point count
return reader.ReadToEnd();
}
private static PluginNavigationPosition Position(
double eastWest,
double northSouth,
double elevation) => new(
0u,
eastWest,
northSouth,
elevation,
0f,
IsOutdoor: true);
/// <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;
target.FollowTargetName = source.FollowTargetName;
target.Waypoints.Clear();
target.Waypoints.AddRange(source.Waypoints.Select(static value => value.Clone()));
}
private static string ReadLine(TextReader reader) =>
reader.ReadLine() ?? throw new EndOfStreamException("Unexpected end of VTank nav data.");
private static int ReadInt(TextReader reader) => int.Parse(
ReadLine(reader),
NumberStyles.Integer,
CultureInfo.InvariantCulture);
private static double ReadDouble(TextReader reader) => double.Parse(
ReadLine(reader),
NumberStyles.Float,
CultureInfo.InvariantCulture);
private static bool ReadBoolean(TextReader reader) => bool.Parse(ReadLine(reader));
}