acdream/tests/AcDream.Plugins.MossTank.Tests/VtankNavRouteSerializerTests.cs
Erik 0219c6e03d fix(vt): A ptl/tlk carry two coordinate triples, jmp direction loss recorded
Item A (slice-1 fix round). VTank/metaf's Portal2/UseNPC nav nodes carry
TWO coordinate triples (metaf_monolithic.py:356-357,11482,11618 —
"FORMAT: ptl/tlk myx myy myz tgtx tgty tgtz tgtObjectClass tgtName"): the
outer header ("myxyz", retail's own dead-weight last-save player position
per docs/research/vtank-kb/06-navigation-and-nav.md section 1.2) and the
embedded d-record ("tgtxyz", the real target coordinate used to match a
live world object by name+class+proximity). The prior port's
RouteWaypoint had a single Position field, so both the .af reader
(MetafSerializer.ReadNavNode) and the binary .nav reader
(VtankNavRouteSerializer.ReadWaypoint, case 6/7) overwrote "myxyz" with
"tgtxyz" on load, and the .af writer echoed the same Position value for
BOTH triples on save — a real .af round trip of the same waypoint was
lossy, which is why aphus/augments/lockandkey/neftet were excluded from
the byte-identity proof.

- RouteWaypoint: new ReferencePosition field (Position stays "myxyz",
  ReferencePosition is "tgtxyz"); included in Clone().
- MetafSerializer.ReadNavNode/RenderNavNode: ptl/tlk read/write both
  triples distinctly. WriteBinaryNavBlob's embedded-route writer (the
  MossTank runtime blob EmbedNav actions carry) fixed the same way — it
  was echoing Position for the reference triple too.
- VtankNavRouteSerializer.ReadWaypoint case 6/7: keep the header triple in
  Position, read the trailing triple into ReferencePosition instead of
  overwriting Position.
- Navigation.TickUse: TryFindObject now searches near ReferencePosition
  (the real target coordinate) instead of Position, preserving the
  correct runtime search behavior now that Position no longer aliases it.
- MossTankPanel.AddSelectedObjectWaypoint: new Portal2/UseNPC waypoints
  now set Position from the live snapshot (matching retail's own
  "wherever the character stood") and ReferencePosition from the selected
  object's live position (the real search anchor) — previously both were
  set from the object's position.
- MossTankRouteProfileStore's WaypointDocument DTO carries the reference
  triple too, so MossTank's own JSON-persisted routes round-trip it.
- MetafSerializerTests: un-excluded aphus/augments/lockandkey/neftet.af
  from the byte-identity proof (they all embed a ptl/tlk node and now
  round-trip correctly) and added example_sort_meta.af, which also
  passes. bore_quest.af was NOT added despite the slice-1 contract's
  ask: it is hand-edited the same way as the already-excluded
  bore_enhanced.af (space instead of tab between "IF:"/"DO:" and the
  following keyword, confirmed at bore_quest.af line 9 — metaf's own
  Rule.ExportToMetAF always joins with a tab, metaf_monolithic.py:12371),
  so it can never byte-match; documented alongside bore_enhanced's
  existing exclusion note instead. New PtlNodeKeepsBothCoordinateTriplesDistinct
  test pins the two-triple split directly (failed before this change:
  Position held the second triple with nowhere to read the first triple
  back from). VtankNavRouteSerializerTests updated to assert the split
  instead of the old collapsed value.
- jmp direction: metaf's NJump class has no strafe-direction field at all
  (metaf_monolithic.py:11708-11821, confirmed reading ImportFromMetAF/
  ExportToMetAF end to end) — the .af format cannot represent
  RouteWaypoint.JumpDirection, full stop. ReadNavNode no longer assigns
  JumpDirection = Forward explicitly (the model's own default), and the
  loss is now recorded as gap 9 in docs/research/vtank-kb/
  06-navigation-and-nav.md section 6.

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

173 lines
5 KiB
C#

using AcDream.Plugin.Abstractions;
namespace AcDream.Plugins.MossTank.Tests;
public sealed class VtankNavRouteSerializerTests
{
[Fact]
public void LoadsEveryOfficialNav12WaypointPayload()
{
const string nav = """
uTank2 NAV 1.2
1
10
0
12.5
-3.25
0.5
0
1
13
-4
0
0
1234
2
14
-5
0
0
987
3
15
-6
0
0
2500
4
16
-7
0
0
/say hello
5
17
-8
0
0
4321
Vendor Bob
6
18
-9
0
0
Portal to Holtburg
14
True
18.5
-9.5
1
7
19
-10
0
0
Town Crier
37
True
19.5
-10.5
2
8
20
-11
0
0
9
21
-12
0
0
180.5
True
1000.00005
""";
var settings = new NavigationSettings { Enabled = true };
bool loaded = VtankNavRouteSerializer.TryLoad(
nav,
settings,
NoOpSpellCatalog.Instance,
out string error);
Assert.True(loaded, error);
Assert.True(settings.Enabled);
Assert.Equal(RouteMode.Circular, settings.Mode);
Assert.Equal(10, settings.Waypoints.Count);
Assert.Equal(RouteWaypointType.Point, settings.Waypoints[0].Type);
Assert.Equal(12.5, settings.Waypoints[0].Position.EastWest);
Assert.Equal(1234u, settings.Waypoints[1].ObjectId);
Assert.Equal(987u, settings.Waypoints[2].RecallSpellId);
Assert.Equal(2500, settings.Waypoints[3].DurationMilliseconds);
Assert.Equal("/say hello", settings.Waypoints[4].Text);
Assert.Equal("Vendor Bob", settings.Waypoints[5].ObjectName);
// Waypoint 6 (Portal2/type 6) carries TWO coordinate triples: the
// outer header (18, myxyz — where the character stood when the
// route was saved, meaningless per the retail decompile) lands in
// Position, and the embedded "d"-record (18.5, tgtxyz — the real
// target used for the world-object search) lands in
// ReferencePosition. A prior port collapsed both onto a single
// Position field, overwriting 18 with 18.5.
Assert.Equal(18, settings.Waypoints[6].Position.EastWest);
Assert.Equal(18.5, settings.Waypoints[6].ReferencePosition.EastWest);
Assert.Equal(-9.5, settings.Waypoints[6].ReferencePosition.NorthSouth);
Assert.Equal(1, settings.Waypoints[6].ReferencePosition.Elevation);
Assert.Equal("Town Crier", settings.Waypoints[7].ObjectName);
Assert.Equal(19, settings.Waypoints[7].Position.EastWest);
Assert.Equal(19.5, settings.Waypoints[7].ReferencePosition.EastWest);
Assert.Equal(14, settings.Waypoints[6].LegacyObjectClass);
Assert.Equal(37, settings.Waypoints[7].LegacyObjectClass);
Assert.True(settings.Waypoints[6].LegacyReferenceValid);
Assert.Equal(RouteWaypointType.Checkpoint, settings.Waypoints[8].Type);
Assert.Equal(180.5f, settings.Waypoints[9].JumpHeadingDegrees);
Assert.True(settings.Waypoints[9].JumpRun);
Assert.Equal(1000, settings.Waypoints[9].JumpChargeMilliseconds);
Assert.Equal(RouteJumpDirection.StrafeRight, settings.Waypoints[9].JumpDirection);
}
[Fact]
public void LoadsEmbeddedWrapperAndDoesNotMutateOnFailure()
{
const string wrapped = """
Route Name
1
uTank2 NAV 1.2
4
1
0
1
2
3
0
""";
var settings = new NavigationSettings();
Assert.True(VtankNavRouteSerializer.TryLoad(
wrapped,
settings,
NoOpSpellCatalog.Instance,
out _));
Assert.Equal(RouteMode.Once, settings.Mode);
Assert.Single(settings.Waypoints);
Assert.False(VtankNavRouteSerializer.TryLoad(
"broken",
settings,
NoOpSpellCatalog.Instance,
out string error));
Assert.NotEmpty(error);
Assert.Equal(RouteMode.Once, settings.Mode);
Assert.Single(settings.Waypoints);
}
private sealed class NoOpSpellCatalog : ISpellCatalog
{
public static NoOpSpellCatalog Instance { get; } = new();
public IReadOnlyList<PluginSpellInfo> KnownSelfBuffs => [];
public bool TryGet(uint spellId, out PluginSpellInfo info)
{
info = default;
return false;
}
}
}