diff --git a/src/EDNexus.App/ViewModels/Cards/RouteCardViewModel.cs b/src/EDNexus.App/ViewModels/Cards/RouteCardViewModel.cs
index 5097092..8f7d468 100644
--- a/src/EDNexus.App/ViewModels/Cards/RouteCardViewModel.cs
+++ b/src/EDNexus.App/ViewModels/Cards/RouteCardViewModel.cs
@@ -369,7 +369,10 @@ private static int RestockCount(RoutePlan plan)
}
/// Rough unladen jump range for the mode hint (full tank, no cargo, incl. Guardian bonus).
- private static double ShipRange(ShipFsdProfile f) => f.JumpRangeAt(f.BaseMass + Math.Min(f.MaxFuelPerJump, f.TankSize)) + f.RangeBoost;
+ // The game's own figure when the Loadout carried one (it includes a Guardian booster); the drive model otherwise.
+ private static double ShipRange(ShipFsdProfile f) => f.MaxJumpRange > 0
+ ? f.MaxJumpRange
+ : f.JumpRangeAt(f.BaseMass + Math.Min(f.MaxFuelPerJump, f.TankSize)) + f.RangeBoost;
[RelayCommand]
private async Task CopyNextSystem()
diff --git a/src/EDNexus.Core/Ship/ShipFsdProfile.cs b/src/EDNexus.Core/Ship/ShipFsdProfile.cs
index 2871307..edd533a 100644
--- a/src/EDNexus.Core/Ship/ShipFsdProfile.cs
+++ b/src/EDNexus.Core/Ship/ShipFsdProfile.cs
@@ -67,21 +67,23 @@ public sealed record ShipFsdProfile(
RangeBoost: rangeBoost,
CargoCapacity: loadout.GetDouble("CargoCapacity") ?? 0,
// The game's own headline figure for this build. Taken verbatim rather than derived:
- // JumpRangeAt() below models the drive for the route plotter and deliberately ignores the
- // Guardian booster, so it is not the number to show a commander.
+ // JumpRangeAt() below models the drive without the Guardian booster, so for a boosted build
+ // it is not the number to show a commander.
MaxJumpRange: loadout.GetDouble("MaxJumpRange") ?? 0);
}
///
- /// The unladen jump range this drive gives at the given mass — the standard FSD formula. Used to
- /// sanity-check extraction against the journal's own MaxJumpRange. Excludes the Guardian
- /// booster, whose in-game bonus is non-linear and modelled server-side by Spansh.
+ /// The jump range this drive gives at the given mass — the standard FSD formula,
+ /// optimalMass / mass × (fuel / fuelMultiplier)^(1 / fuelPower), with fuel the most one
+ /// jump may burn. At BaseMass + MaxFuelPerJump it reproduces the journal's own
+ /// MaxJumpRange exactly for any build without a Guardian booster. Excludes that booster,
+ /// whose in-game bonus is non-linear and modelled server-side by Spansh.
///
public double JumpRangeAt(double mass)
{
if (mass <= 0 || FuelMultiplier <= 0 || FuelPower <= 0) return 0;
var fuel = Math.Min(MaxFuelPerJump, TankSize);
- return OptimalMass / mass * Math.Pow(1000 * fuel / FuelMultiplier, 1 / FuelPower);
+ return OptimalMass / mass * Math.Pow(fuel / FuelMultiplier, 1 / FuelPower);
}
/// Read the engineered FSDOptimalMass / MaxFuelPerJump overrides the journal records on the module.
diff --git a/src/EDNexus.Core/Twitch/StreamCardMapper.cs b/src/EDNexus.Core/Twitch/StreamCardMapper.cs
index e1d7ed6..8f4f478 100644
--- a/src/EDNexus.Core/Twitch/StreamCardMapper.cs
+++ b/src/EDNexus.Core/Twitch/StreamCardMapper.cs
@@ -183,10 +183,9 @@ private static StreamCardShip MapShip(CommanderState state) =>
/// until one has been seen.
///
///
- /// Deliberately not derived from : that models the
- /// drive for the route plotter and excludes the Guardian booster, so it does not agree with the
- /// figure on the commander's own ship panel — and a viewer comparing the two would see the card
- /// as simply wrong.
+ /// Deliberately not derived from : that excludes the
+ /// Guardian booster, so for a boosted build it does not agree with the figure on the commander's
+ /// own ship panel — and a viewer comparing the two would see the card as simply wrong.
///
private static double? JumpRange(CommanderState state) =>
state.Fsd is { MaxJumpRange: > 0 } fsd ? Math.Round(fsd.MaxJumpRange, 2) : null;
diff --git a/tests/EDNexus.Tests/Ship/ShipFsdProfileTests.cs b/tests/EDNexus.Tests/Ship/ShipFsdProfileTests.cs
index 5bd6a9c..3e83366 100644
--- a/tests/EDNexus.Tests/Ship/ShipFsdProfileTests.cs
+++ b/tests/EDNexus.Tests/Ship/ShipFsdProfileTests.cs
@@ -89,10 +89,30 @@ public void Jump_range_falls_with_mass_and_matches_the_formula()
OptimalMass: 1050, BaseMass: 280, TankSize: 32, ReserveSize: 0.63,
FuelMultiplier: 0.012, FuelPower: 2.45, MaxFuelPerJump: 5, RangeBoost: 0, CargoCapacity: 0);
- // range = optmass/mass * (1000*fuel/mul)^(1/power); fuel = min(maxfuel, tank) = 5.
- var expectedAt400 = 1050.0 / 400.0 * System.Math.Pow(1000 * 5 / 0.012, 1 / 2.45);
+ // range = optmass/mass * (fuel/mul)^(1/power); fuel = min(maxfuel, tank) = 5.
+ var expectedAt400 = 1050.0 / 400.0 * System.Math.Pow(5 / 0.012, 1 / 2.45);
Assert.Equal(expectedAt400, fsd.JumpRangeAt(400), 3);
+ Assert.InRange(fsd.JumpRangeAt(400), 25, 35); // a 5A on a mid-weight ship: tens of ly, not hundreds
Assert.True(fsd.JumpRangeAt(300) > fsd.JumpRangeAt(500)); // heavier ships jump shorter
}
+
+ [Fact]
+ public void Drive_model_reproduces_the_range_the_game_reports_for_a_real_build()
+ {
+ // An engineered long-range SCO drive on an Anaconda Mk II, exactly as a recent Loadout wrote it.
+ // The game's headline range is the unladen ship plus the fuel for one jump.
+ var e = Loadout("""
+ { "event": "Loadout", "Ship": "anacondamkii", "UnladenMass": 2253.880127, "MaxJumpRange": 34.576313,
+ "FuelCapacity": { "Main": 64.0, "Reserve": 1.08 },
+ "Modules": [ { "Slot": "FrameShiftDrive", "Item": "int_hyperdrive_overcharge_size8_class5", "On": true,
+ "Engineering": { "BlueprintName": "FSD_LongRange", "Level": 3, "Modifiers": [
+ { "Label": "Mass", "Value": 192.0, "OriginalValue": 160.0 },
+ { "Label": "FSDOptimalMass", "Value": 6187.75, "OriginalValue": 4670.0 } ] } } ] }
+ """);
+
+ var fsd = ShipFsdProfile.FromLoadout(e)!;
+
+ Assert.Equal(34.576313, fsd.JumpRangeAt(fsd.BaseMass + fsd.MaxFuelPerJump), 3);
+ }
}
diff --git a/tests/EDNexus.Tests/Twitch/StreamCardMapperTests.cs b/tests/EDNexus.Tests/Twitch/StreamCardMapperTests.cs
index 0a5918e..39e7853 100644
--- a/tests/EDNexus.Tests/Twitch/StreamCardMapperTests.cs
+++ b/tests/EDNexus.Tests/Twitch/StreamCardMapperTests.cs
@@ -307,9 +307,9 @@ public void A_full_card_stays_inside_the_five_kibibyte_pubsub_cap()
[Fact]
public void Jump_range_is_the_figure_the_game_reports_not_a_derived_one()
{
- // Real values off an Anaconda's Loadout: the game says 25.05 ly, while the route plotter's
- // drive model (JumpRangeAt) answers in the hundreds because it is solving a different
- // problem. A viewer comparing the card to the commander's ship panel must see 25.05.
+ // The card shows what the Loadout reported (25.05 ly here), not a figure re-derived from the
+ // drive model — which leaves out a Guardian booster. A viewer comparing the card to the
+ // commander's ship panel must see 25.05.
var state = new CommanderState
{
Ship = "anaconda",