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