fix(vt): I widen tDouble-declared distance fields from float to double

Item I (slice-1 fix round, finishing item 1). CaptureMatchesDeclaredSettingTypeAndValue's
~1.5e-7 float-round-trip tolerance was masking a real cause, not a real
value change: every tDouble-declared distance setting whose live field
was actually a float (CombatSettings.MaximumRange/MinimumRange/
ApproachDistance/RingDistance/ArcRange/TargetSelectAngleRange/
PetCustomRange/CollisionProjectileRadius/CollisionStepDistance/
SpellRangeFudge, Looting.CorpseApproachRange/CorpseMinimumApproachRange,
VitalPlan.HelperHealthDistance/HelperStaminaDistance/HelperManaDistance)
lost precision below float's ~7-significant-digit guarantee on every
load, since a loaded .usd's real value is a double.

- All 15 fields widened from float to double, matching their declared
  tDouble type. Every physics/combat call site that genuinely needs a
  float (IProjectileAutomation.EvaluatePath/EvaluatePathWithDiagnostics,
  ICombatAutomation.CaptureHostileTargets/CaptureCorpses, the
  fellow-distance Lowest() helper) now casts explicitly at that one use
  site (CombatController.cs, Looting.cs, VitalRecharge.cs,
  MossTankCommands.cs, PetAutomation.cs) instead of the field itself
  being narrowed everywhere it's stored.
- MossTankProfileStore's JSON DTOs (InventoryProfileDocument.
  CorpseApproachRange, CombatProfileDocument.MaximumRange/
  ApproachDistance/TargetSelectAngleRange/ArcRange/RingDistance/
  PetCustomRange) widened to match, so MossTank's own persisted profiles
  keep full precision too — their Apply()-side Math.Clamp calls needed no
  changes (the float literal bounds like 2f/100f already widen to the
  double overload implicitly).
- VtankSettingsProfileSerializer.Apply's `(float)(cell.AsDouble() * 240d)`
  casts and `cell.AsFloat()` calls for these 15 settings are now plain
  `cell.AsDouble()` / `cell.AsDouble() * 240d` — no narrowing at all.
- ValuesEqual's "d"/"f" branch dropped DoublesEqual/FloatRoundTripTolerance
  entirely: `a.AsDouble() == b.AsDouble()`, exact, matching every other
  tag. CaptureMatchesDeclaredSettingTypeAndValue's own test-side tolerance
  (a second, independently-tolerant comparison) removed the same way —
  all 135 catalog names now pass under Assert.Equal(exact) with zero
  special-casing.

Full MossTank suite: 581/581 (no count change — this is a precision fix,
not new coverage; CaptureMatchesDeclaredSettingTypeAndValue's own 135
cases already existed and now pass exactly instead of within tolerance).
App.Tests (Plugin|LaunchOptions filter): 84/84.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-09-06 23:27:09 +02:00
parent a3b4a8e2e5
commit 8fd3999616
10 changed files with 79 additions and 87 deletions

View file

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

View file

@ -69,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;
@ -101,15 +106,15 @@ internal sealed class CombatSettings
public double DebuffPrecastSeconds { get; set; } = 5d;
public bool SwitchWandsToDebuff { get; set; }
public UseArcsMode UseArcs { get; set; } = UseArcsMode.AtRange;
public float SpellRangeFudge { get; set; } = 1f;
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;
@ -119,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;

View file

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

View file

@ -754,9 +754,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,

View file

@ -430,7 +430,7 @@ internal sealed class MossTankProfileStore
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 double CorpseApproachRange { get; set; } = 40d;
public double CorpseOpenTimeoutSeconds { get; set; } = 1.5d;
public int BlacklistCorpseOpenAttemptCount { get; set; } = 30;
public double BlacklistCorpseOpenTimeoutSeconds { get; set; } = 200d;
@ -597,12 +597,12 @@ internal sealed class MossTankProfileStore
private sealed class CombatProfileDocument
{
public bool Enabled { get; set; } = true;
public float MaximumRange { get; set; } = 5f;
public float ApproachDistance { get; set; }
public double MaximumRange { get; set; } = 5d;
public double ApproachDistance { get; set; }
public bool IdlePeaceMode { 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;
@ -616,8 +616,8 @@ internal sealed class MossTankProfileStore
public double DebuffPrecastSeconds { get; set; } = 5d;
public bool SwitchWandsToDebuff { get; set; }
public UseArcsMode UseArcs { get; set; } = UseArcsMode.AtRange;
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;
@ -627,7 +627,7 @@ internal sealed class MossTankProfileStore
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;

View file

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

View file

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

View file

@ -147,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,
@ -158,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

View file

@ -147,17 +147,17 @@ internal static class VtankSettingsProfileSerializer
};
/// <summary>
/// Exact compare on the cell's own type — a <see cref="Capture"/> tag
/// mismatch is now a real bug (fixed above), not something to paper
/// over with a tolerance. The one narrow exception: several distance
/// settings (AttackDistance, ArcRange, …) are declared <c>tDouble</c>
/// but this port still stores their live value in a <c>float</c> field
/// (CombatSettings.MaximumRange etc.) for the physics/combat math that
/// consumes them; round-tripping through that float costs precision
/// below the 15th significant digit, which is exactly float's ~7-digit
/// guarantee, not a real value change. That residual is bounded by
/// <see cref="FloatRoundTripTolerance"/>, not an arbitrary fudge factor.
/// Every other tag (bool/int/enum/string/uint) compares byte-exact.
/// 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)
{
@ -169,23 +169,11 @@ internal static class VtankSettingsProfileSerializer
"s" => a.AsString() == b.AsString(),
"i" => a.AsInt() == b.AsInt(),
"u" => a.AsUInt() == b.AsUInt(),
"d" or "f" => DoublesEqual(a.AsDouble(), b.AsDouble()),
"d" or "f" => a.AsDouble() == b.AsDouble(),
_ => ReferenceEquals(a, b),
};
}
private static bool DoublesEqual(double left, double right)
{
double tolerance = Math.Max(Math.Abs(left), Math.Abs(right)) * FloatRoundTripTolerance;
return Math.Abs(left - right) <= tolerance;
}
// ~1.2e-7 is float's relative machine epsilon (2^-23); a value that has
// been through one float round trip (Apply's (float)(... * 240d) cast)
// can differ from its original double by up to this much and still be
// the SAME value VTank wrote.
private const double FloatRoundTripTolerance = 1.5e-7;
// ------------------------------------------------------------------
// Apply: .usd cell -> live setting.
// ------------------------------------------------------------------
@ -219,18 +207,18 @@ internal static class VtankSettingsProfileSerializer
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 = (float)(cell.AsDouble() * 240d); break;
case "attackminimumdistance": c.MinimumRange = (float)(cell.AsDouble() * 240d); break;
case "approachdistance": c.ApproachDistance = (float)(cell.AsDouble() * 240d); break;
case "ringdistance": c.RingDistance = (float)(cell.AsDouble() * 240d); break;
case "corpseapproachrange-max": i.Loot.CorpseApproachRange = (float)(cell.AsDouble() * 240d); break;
case "corpseapproachrange-min": i.Loot.CorpseMinimumApproachRange = (float)(cell.AsDouble() * 240d); 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 = (float)(cell.AsDouble() * 240d); break;
case "helperdistancestam": v.HelperStaminaDistance = (float)(cell.AsDouble() * 240d); break;
case "helperdistancemana": v.HelperManaDistance = (float)(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;
@ -252,9 +240,9 @@ internal static class VtankSettingsProfileSerializer
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 = (float)(cell.AsDouble() * 240d); break;
case "arcrange": c.ArcRange = cell.AsDouble() * 240d; break;
case "targetselectmethod": c.SelectionMethod = (TargetSelectionMethod)(cell.AsInt() - 1); break;
case "targetselectanglerange": c.TargetSelectAngleRange = (float)(cell.AsDouble() * 240d); 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;
@ -323,7 +311,7 @@ internal static class VtankSettingsProfileSerializer
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 = (float)(cell.AsDouble() * 240d); 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;
@ -332,11 +320,11 @@ internal static class VtankSettingsProfileSerializer
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.AsFloat(); break;
case "collisionstepdistance": c.CollisionStepDistance = cell.AsFloat(); 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.AsFloat(); 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;