fix(vt): model UseArcs as VTank's real 3-way enum, not a collapsed bool

Fidelity blocker 2: VTank's UseArcs is 1=No / 2=AtRange / 3=Yes
(refs/vtank/decompiled/hi.cs:515-538, switch on f3.f("UseArcs")): No
always picks the direct-shape spell, AtRange picks arc only once the
target reaches ArcRange, and Yes always picks arc regardless of
distance. The prior port collapsed this onto CombatSettings.UseArcs
(bool), which can represent No and (an approximation of) AtRange but
has no way to express Yes — a profile with UseArcs=3 could never
actually always-arc, and Capture() had to fudge 1<->3 on save,
which is why UntouchedRoundTripIsByteIdentical needed a NormalizeUseArcs
special case.

- CombatSettings: new UseArcsMode enum (No=1, AtRange=2, Yes=3);
  UseArcs is now UseArcsMode (default AtRange, matching the previous
  bool default's runtime behavior).
- AttackSpellCatalog.ShouldUseArc implements the real 3-way switch,
  replacing the `settings.UseArcs && target.Distance >= settings.ArcRange`
  expression at both call sites (Yes now genuinely always arcs).
- VtankSettingsProfileSerializer Apply/Capture "usearcs" cases now
  cast directly to/from UseArcsMode instead of the `!= 0` / `? 3 : 1`
  bool collapse.
- MossTankPanel GetMetaOption/SetMetaOption "usearcs" now exposes the
  raw 1-3 value (ExpressionValue.Number / AsInt32 clamped 1-3),
  matching the existing pattern for DebuffEachFirst/DebuffSelectionMethod,
  instead of ExpressionValue.Boolean/IsTruthy.
- MossTankProfileStore's JSON DTO field type follows suit (System.Text.Json
  already serializes CombatSettings' other enum settings the same way).
- Deleted NormalizeUseArcs from UntouchedRoundTripIsByteIdentical —
  the test now asserts full byte-identity with no special case, and
  CaptureMatchesDeclaredSettingTypeAndValue (added in the previous
  commit) no longer needs to skip "UseArcs".

Verification: reverting to the bool model reproduces exactly one
theory failure (CaptureMatchesDeclaredSettingTypeAndValue("UseArcs"):
expected 1, actual 3) confirming this is the only affected setting;
after this change the full 559-test suite passes with zero special
cases.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-09-06 21:47:03 +02:00
parent 9c34b6074d
commit 74283e1116
7 changed files with 45 additions and 32 deletions

View file

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

View file

@ -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,
@ -83,7 +100,7 @@ internal sealed class CombatSettings
DebuffSelectionMethod.Skill;
public double DebuffPrecastSeconds { get; set; } = 5d;
public bool SwitchWandsToDebuff { get; set; }
public bool UseArcs { get; set; } = true;
public UseArcsMode UseArcs { get; set; } = UseArcsMode.AtRange;
public float SpellRangeFudge { get; set; } = 1f;
public bool UseBreakableTurnTo { get; set; } = true;
public bool UseProjectileAwareness { get; set; } = true;

View file

@ -2528,7 +2528,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 +2998,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;

View file

@ -615,7 +615,7 @@ internal sealed class MossTankProfileStore
DebuffSelectionMethod.Skill;
public double DebuffPrecastSeconds { get; set; } = 5d;
public bool SwitchWandsToDebuff { get; set; }
public bool UseArcs { get; set; } = true;
public UseArcsMode UseArcs { get; set; } = UseArcsMode.AtRange;
public float ArcRange { get; set; } = 5f;
public float RingDistance { get; set; } = 5f;
public int MinimumRingTargets { get; set; } = 4;

View file

@ -261,7 +261,7 @@ internal static class VtankSettingsProfileSerializer
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 = cell.AsInt() != 0; break;
case "usearcs": c.UseArcs = (UseArcsMode)cell.AsInt(); break;
case "arcrange": c.ArcRange = (float)(cell.AsDouble() * 240d); break;
case "targetselectmethod": c.SelectionMethod = (TargetSelectionMethod)(cell.AsInt() - 1); break;
case "targetselectanglerange": c.TargetSelectAngleRange = (float)(cell.AsDouble() * 240d); break;
@ -427,7 +427,7 @@ internal static class VtankSettingsProfileSerializer
"idlepeacemode" => VtankCell.Bool(c.IdlePeaceMode),
"targetlock" => VtankCell.Bool(c.TargetLock),
"stopmacroondeath" => VtankCell.Bool(c.StopMacroOnDeath),
"usearcs" => Num(name, c.UseArcs ? 3 : 1),
"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),

View file

@ -58,7 +58,7 @@ public sealed class AttackSpellCatalogTests
IsProjectile = true,
},
]);
var settings = new CombatSettings { UseArcs = true, ArcRange = 15 };
var settings = new CombatSettings { UseArcs = UseArcsMode.AtRange, ArcRange = 15 };
var actions = new MonsterRuleActions
{
DamageType = MonsterDamageType.Fire,
@ -215,7 +215,7 @@ public sealed class AttackSpellCatalogTests
IReadOnlyList<AttackSpellChoice> choices = catalog.Candidates(
new MonsterRuleActions { DamageType = MonsterDamageType.Auto },
new CombatSettings { UseArcs = false },
new CombatSettings { UseArcs = UseArcsMode.No },
target,
0,
Character.Instance);

View file

@ -40,20 +40,9 @@ public sealed class VtankSettingsProfileSerializerTests
// VTank's own value range (Save() only overwrites a Settings row's
// Value cell when the live value actually differs from what was
// parsed, using a tolerance that absorbs float<->double formatting
// noise but not a real value change).
//
// One documented exception: "UseArcs" is a real, pre-existing,
// already-shipped simplification (MossTankPanel.cs:3000-3002,
// "usearcs" case) that collapses VTank's 3-way enum (1=No, 2=AtRange,
// 3=Yes) onto a single bool, matching ExpressionValue.IsTruthy's
// "!= 0" semantics for the *live* /vt-style setting path. A profile
// whose original value is exactly 1 (No) genuinely cannot round-trip
// byte-for-byte through that bool — Capture() must produce either 1 or
// 3, and both this serializer and the live SetMetaOption path treat 1
// and 3 identically on load (both truthy). This is intentionally
// reproduced here rather than diverging from the already-accepted
// live-path behavior; the assertion below normalizes UseArcs before
// comparing so the test still proves everything else is untouched.
// noise but not a real value change). UseArcs is now modeled as
// VTank's real 3-way enum (CombatSettings.UseArcsMode), so no
// normalization is needed — every setting round-trips untouched.
[Theory]
[MemberData(nameof(UsdFixtureData))]
public void UntouchedRoundTripIsByteIdentical(string path)
@ -62,14 +51,9 @@ public sealed class VtankSettingsProfileSerializerTests
VtankSettingsProfileSerializer.AllSettings settings = NewSettings();
VtankDatabase document = VtankSettingsProfileSerializer.Load(original, settings);
string rewritten = VtankSettingsProfileSerializer.Save(document, settings);
Assert.Equal(NormalizeUseArcs(original), NormalizeUseArcs(rewritten));
Assert.Equal(original, rewritten);
}
private static string NormalizeUseArcs(string usd) => usd.Replace(
"UseArcs\r\ni\r\n1\r\n",
"UseArcs\r\ni\r\n3\r\n",
StringComparison.Ordinal);
[Fact]
public void DefaultSettingsUsdMatchesEveryCatalogDefault()
{
@ -147,8 +131,6 @@ public sealed class VtankSettingsProfileSerializerTests
{
if (name is "EnableMeta" or "RechargeHandlerSet")
continue; // no live write path (Every137CatalogNameMapsToExactlyOneField).
if (name == "UseArcs")
continue; // pre-existing bool-collapse simplification; see UntouchedRoundTripIsByteIdentical.
data.Add(name);
}
return data;