Scenario Briefs (Crossing, Duel, FusedRounds, LauncherAngles, StarvedReactor, WeaponFeel), scenario and test comments and test locals say the design names (Lightning Projector, Light Laser, Cell Blaster, Guided Missile Launcher, Swarm Rocket Pod, Muon Fusion Reactor, Cluster Cannon, Charged Scatter Blaster, Rugged Drive, Racing Drive) instead of the retired brand names; Crossing.PlightRange is ProjectorRange. generic-designs: the batch carries ExpectUnchanged so every record it does not author keeps its bytes (the plain commit re-encoded four records, dropping SensorData key 7). DesignTexts rewrites Large Drive, Tractor Beam, Smoke Proximity, Smoke Delayed, Smoke Refused and Smoke Refused Charge as tech-node text; the Tractor Beam's old line moves to its product Space Tractor. smoke-rounds' authored text names Light Laser and Charged Scatter Blaster to match. Tests: NoScenarioBriefNamesRetiredGear, TheCapacitorDesignCarriesItsRoleMap, NoDesignTextNamesAProduct, and a command test that widens every payload and checks apply changes only the three records it authors and restores the rewritten texts. Not built or run at this commit; GameData/Aetheria.cc is regenerated in the next commit, so NoDesignTextNamesAProduct and the command test fail here. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
346 lines
20 KiB
C#
346 lines
20 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using GameCult.Caching;
|
|
using GameCult.Caching.MessagePack;
|
|
using CultMath;
|
|
using Newtonsoft.Json;
|
|
using Xunit;
|
|
|
|
// Aetheria release, cut missile-stats (ruling missile-stats-now-gear-later): a missile type's stats are five gear-part
|
|
// behaviours on the launcher design. The shipped catalog is the oracle for what the migration copied and authored.
|
|
public sealed class MunitionPartsTests : IDisposable
|
|
{
|
|
private readonly string _root = Path.Combine(Path.GetTempPath(), "munition-parts-" + Guid.NewGuid().ToString("N"));
|
|
private string Catalog => Path.Combine(_root, "Aetheria.cc");
|
|
|
|
public MunitionPartsTests() => Directory.CreateDirectory(_root);
|
|
public void Dispose() => Directory.Delete(_root, true);
|
|
|
|
// The flight stats of the five designs that carried LauncherData or GuidedWeaponData at b6262467 (Thrust, MissileVelocity
|
|
// and DodgeFrequency), read from that revision's catalog before the fields were retired. All five read role thruster on
|
|
// both ranged stats. The column names are the retired fields'.
|
|
private static readonly (string Design, float ThrustMin, float ThrustMax, float MissileVelocityMin, float MissileVelocityMax, float DodgeFrequency)[] Golden =
|
|
{
|
|
("Guided Missile Launcher", 135, 325, 150, 300, 0.25f),
|
|
("Long-Range Missile Rack", 400, 600, 200, 300, 0.25f),
|
|
("Short-Range Missile Rack", 1600, 2400, 400, 600, 1f),
|
|
("Swarm Rocket Pod", 500, 2000, 400, 600, 1f),
|
|
("Incendiary Rocket Pod", 500, 2000, 400, 600, 1f),
|
|
};
|
|
|
|
private static PerformanceStat Flat(float value) => new PerformanceStat { Min = value, Max = value };
|
|
|
|
private static PerformanceStat Ranged(float min, float max, string role) => role == null ? Flat(max) : new PerformanceStat
|
|
{
|
|
Min = min, Max = max, Terms = new List<StatTerm> { new StatTerm { Source = StatSource.Quality, Exponent = 1.5f, Role = role } },
|
|
};
|
|
|
|
// The five parts a munition design carries, every field set to a distinct value so a field read from the wrong slot or
|
|
// dropped on the way is seen. The thruster's stats vary with the given role's quality when one is named; the design
|
|
// that carries them declares it. Fixtures that only need a valid munition pass none.
|
|
internal static List<BehaviorData> Parts(int group, string role = null) => new List<BehaviorData>
|
|
{
|
|
new MunitionHullData { Group = group, Durability = Flat(21), Radius = Flat(4.5f) },
|
|
new MunitionThrusterData { Group = group, Acceleration = Ranged(300, 400, role), Lateral = Ranged(150, 200, role), MaxSpeed = Ranged(250, 350, role), Visibility = Flat(11) },
|
|
new MunitionSeekerData { Group = group, Sensitivity = Flat(.8f), BeamWidth = Flat(55), Range = Flat(900) },
|
|
new MunitionControlData { Group = group, Navigation = Flat(3.5f), Evasion = Flat(.4f), EvasionFrequency = Flat(.6f), Lifetime = Flat(12), Cognition = Flat(.35f) },
|
|
new MunitionFuelData { Group = group, DeltaV = Flat(700) },
|
|
};
|
|
|
|
private static string Describe(PerformanceStat stat) =>
|
|
$"{stat.Min}..{stat.Max} [{string.Join(",", stat.Terms.Select(t => $"{t.Source}^{t.Exponent}@{t.Role}"))}]";
|
|
|
|
private static string[] Describe(BehaviorData part) =>
|
|
part.GetType().GetFields().Where(f => f.FieldType == typeof(PerformanceStat))
|
|
.Select(f => $"{part.GetType().Name}.{f.Name} {Describe((PerformanceStat) f.GetValue(part))}").Prepend($"{part.GetType().Name} group {part.Group}").ToArray();
|
|
|
|
private static WeaponItemData Munition(string name, BehaviorData launcher, int group = 0)
|
|
{
|
|
launcher.Group = group;
|
|
var design = new WeaponItemData { Name = name, Roles = new List<ItemRole> { new ItemRole { Name = "motor" } } };
|
|
design.Behaviors.Add(launcher);
|
|
design.Behaviors.AddRange(Parts(group, "motor"));
|
|
return design;
|
|
}
|
|
|
|
private static T Part<T>(EquippableItemData design) where T : MunitionPartData => design.Behaviors.OfType<T>().Single();
|
|
|
|
[Fact]
|
|
public void MunitionPartsRoundTrip()
|
|
{
|
|
var designs = new[] { Munition("Round A", new LauncherData(), 2), Munition("Round B", new GuidedWeaponData(), 3) };
|
|
using (var cache = AetheriaStores.Open(Catalog, catalogWritable: true))
|
|
{
|
|
cache.Upsert(new VerseGrammar { Revision = 1 });
|
|
foreach (var design in designs) cache.Upsert(design);
|
|
cache.FlushAsync().Wait();
|
|
}
|
|
|
|
using var reopened = AetheriaStores.Open(Catalog);
|
|
foreach (var design in designs)
|
|
{
|
|
var stored = reopened.GetByName<WeaponItemData>(design.Name);
|
|
var packed = stored.Behaviors.OfType<MunitionPartData>().ToArray();
|
|
Assert.Equal(new[] { typeof(MunitionHullData), typeof(MunitionThrusterData), typeof(MunitionSeekerData), typeof(MunitionControlData), typeof(MunitionFuelData) },
|
|
packed.Select(p => p.GetType()));
|
|
var expected = design.Behaviors.OfType<MunitionPartData>().ToArray();
|
|
for (var i = 0; i < expected.Length; i++)
|
|
{
|
|
Assert.Equal(Describe(expected[i]), Describe(packed[i]));
|
|
var json = (MunitionPartData) JsonConvert.DeserializeObject(JsonConvert.SerializeObject(expected[i]), expected[i].GetType());
|
|
Assert.Equal(Describe(expected[i]), Describe(json));
|
|
}
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void MigrationCopiesTheFlightStats()
|
|
{
|
|
using var cache = RestoredHullsTests.OpenCatalog();
|
|
foreach (var (name, thrustMin, thrustMax, speedMin, speedMax, dodge) in Golden)
|
|
{
|
|
var design = cache.GetByName<WeaponItemData>(name);
|
|
var thruster = Part<MunitionThrusterData>(design);
|
|
Assert.Equal((thrustMin, thrustMax), (thruster.Acceleration.Min, thruster.Acceleration.Max));
|
|
Assert.Equal((thrustMin / 2f, thrustMax / 2f), (thruster.Lateral.Min, thruster.Lateral.Max));
|
|
Assert.Equal((speedMin, speedMax), (thruster.MaxSpeed.Min, thruster.MaxSpeed.Max));
|
|
// The terms come across whole: the role's Quality term and whatever heat or durability terms the stat carried.
|
|
Assert.Equal(Describe(thruster.Acceleration).Split('[')[1], Describe(thruster.Lateral).Split('[')[1]);
|
|
foreach (var stat in new[] { thruster.Acceleration, thruster.Lateral, thruster.MaxSpeed })
|
|
Assert.Equal("thruster", Assert.Single(stat.Terms, t => t.Source == StatSource.Quality).Role);
|
|
|
|
var evasion = Part<MunitionControlData>(design).EvasionFrequency;
|
|
Assert.Equal((dodge, dodge), (evasion.Min, evasion.Max));
|
|
Assert.Empty(evasion.Terms);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void EveryLauncherCarriesAuthoredParts()
|
|
{
|
|
using var cache = RestoredHullsTests.OpenCatalog();
|
|
var munitions = cache.GetAll<EquippableItemData>()
|
|
.Where(d => d.Behaviors.Any(b => b is LauncherData || b is GuidedWeaponData)).ToArray();
|
|
Assert.Equal(Golden.Select(g => g.Design).OrderBy(n => n, StringComparer.Ordinal), munitions.Select(m => m.Name).OrderBy(n => n, StringComparer.Ordinal));
|
|
foreach (var design in munitions)
|
|
{
|
|
var parts = design.Behaviors.OfType<MunitionPartData>().Select(p => p.GetType()).OrderBy(t => t.Name, StringComparer.Ordinal).ToArray();
|
|
Assert.Equal(new[] { typeof(MunitionControlData), typeof(MunitionFuelData), typeof(MunitionHullData), typeof(MunitionSeekerData), typeof(MunitionThrusterData) }, parts);
|
|
var weapon = design.Behaviors.OfType<WeaponData>().Single();
|
|
var hull = Part<MunitionHullData>(design);
|
|
var thruster = Part<MunitionThrusterData>(design);
|
|
var seeker = Part<MunitionSeekerData>(design);
|
|
var control = Part<MunitionControlData>(design);
|
|
var fuel = Part<MunitionFuelData>(design);
|
|
foreach (var (label, stat) in new[]
|
|
{
|
|
("Durability", hull.Durability), ("Radius", hull.Radius), ("Acceleration", thruster.Acceleration), ("Lateral", thruster.Lateral),
|
|
("MaxSpeed", thruster.MaxSpeed), ("Visibility", thruster.Visibility), ("Sensitivity", seeker.Sensitivity), ("BeamWidth", seeker.BeamWidth),
|
|
("Seeker Range", seeker.Range), ("Navigation", control.Navigation), ("Lifetime", control.Lifetime), ("DeltaV", fuel.DeltaV),
|
|
})
|
|
Assert.True(stat.Min > 0f && stat.Max > 0f, $"{design.Name}: {label} is not above zero");
|
|
Assert.True(control.Cognition.Min > 0f && control.Cognition.Max <= 1f, $"{design.Name}: Cognition is not in (0, 1]");
|
|
var expected = 1.5f * weapon.Range.Max;
|
|
Assert.InRange(control.Lifetime.Max * thruster.MaxSpeed.Max, .99f * expected, 1.01f * expected);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void ValidationRefusesIncompleteMunitions()
|
|
{
|
|
CultRecordRefs.Validate(Munition("Complete", new LauncherData()));
|
|
|
|
// Each of the five parts is required, on either launcher behaviour: a design missing it is refused naming the design
|
|
// and the part.
|
|
InvalidOperationException refusal;
|
|
foreach (var part in AllParts)
|
|
foreach (var launcher in new Func<BehaviorData>[] { () => new LauncherData(), () => new GuidedWeaponData() })
|
|
{
|
|
var missing = Munition("No " + part.Name, launcher());
|
|
missing.Behaviors.RemoveAll(b => b.GetType() == part);
|
|
refusal = Assert.Throws<InvalidOperationException>(() => CultRecordRefs.Validate(missing));
|
|
Assert.Contains(missing.Name, refusal.Message);
|
|
Assert.Contains(part.Name, refusal.Message);
|
|
}
|
|
|
|
var doubled = Munition("Two Seekers", new GuidedWeaponData());
|
|
doubled.Behaviors.Add(new MunitionSeekerData());
|
|
refusal = Assert.Throws<InvalidOperationException>(() => CultRecordRefs.Validate(doubled));
|
|
Assert.Contains("Two Seekers", refusal.Message);
|
|
Assert.Contains(nameof(MunitionSeekerData), refusal.Message);
|
|
|
|
var stray = new WeaponItemData { Name = "Stray Part" };
|
|
stray.Behaviors.Add(new MunitionHullData());
|
|
refusal = Assert.Throws<InvalidOperationException>(() => CultRecordRefs.Validate(stray));
|
|
Assert.Contains("Stray Part", refusal.Message);
|
|
Assert.Contains(nameof(MunitionHullData), refusal.Message);
|
|
|
|
// A consumable is a design too, and carries no munition part.
|
|
var potion = new ConsumableItemData { Name = "Stray Potion" };
|
|
potion.Behaviors.Add(new MunitionFuelData());
|
|
refusal = Assert.Throws<InvalidOperationException>(() => CultRecordRefs.Validate(potion));
|
|
Assert.Contains("Stray Potion", refusal.Message);
|
|
Assert.Contains(nameof(MunitionFuelData), refusal.Message);
|
|
}
|
|
|
|
private static readonly Type[] AllParts =
|
|
{ typeof(MunitionHullData), typeof(MunitionThrusterData), typeof(MunitionSeekerData), typeof(MunitionControlData), typeof(MunitionFuelData) };
|
|
|
|
// A catalog file holding the shipped catalog with one design edited around validation (the way a hand-edited file or a
|
|
// bare-cache tool writes), for the production load path to meet.
|
|
private string Forged<T>(Func<T, bool> pick, Action<T> edit) where T : ItemData
|
|
{
|
|
var path = Path.Combine(_root, "forged-" + Guid.NewGuid().ToString("N") + ".cc");
|
|
File.Copy(TestCatalog.Repo, path);
|
|
var cache = new CultCache(TestCatalog.Registry());
|
|
cache.AddBackingStore(new SingleFileMessagePackBackingStore(path, false), AetheriaStores.CatalogTypes);
|
|
var design = cache.GetAll<T>().First(pick);
|
|
edit(design);
|
|
var key = cache.RefOf(design).Key;
|
|
cache.Commit(batch => batch.Upsert(design.GetType(), design, key));
|
|
cache.FlushAsync().Wait();
|
|
cache.Dispose();
|
|
return path;
|
|
}
|
|
|
|
[Fact]
|
|
public void CatalogLoadRefusesAnIncompleteOrStrayMunitionPart()
|
|
{
|
|
using (AetheriaStores.Open(Forged<WeaponItemData>(d => d.Name == "Guided Missile Launcher", _ => { }), registry: TestCatalog.Registry())) { }
|
|
|
|
foreach (var part in AllParts)
|
|
{
|
|
var path = Forged<WeaponItemData>(d => d.Name == "Guided Missile Launcher", d => d.Behaviors.RemoveAll(b => b.GetType() == part));
|
|
var refusal = Assert.ThrowsAny<Exception>(() => AetheriaStores.Open(path, registry: TestCatalog.Registry()));
|
|
Assert.Contains("Guided Missile Launcher", refusal.ToString());
|
|
Assert.Contains(part.Name, refusal.ToString());
|
|
}
|
|
|
|
var gear = Forged<GearData>(d => d.Behaviors.Any(b => b is ThrusterData), d => d.Behaviors.Add(new MunitionHullData()));
|
|
Assert.Contains(nameof(MunitionHullData), Assert.ThrowsAny<Exception>(() => AetheriaStores.Open(gear, registry: TestCatalog.Registry())).ToString());
|
|
|
|
var consumable = Forged<ConsumableItemData>(_ => true, d => d.Behaviors.Add(new MunitionFuelData()));
|
|
Assert.Contains(nameof(MunitionFuelData), Assert.ThrowsAny<Exception>(() => AetheriaStores.Open(consumable, registry: TestCatalog.Registry())).ToString());
|
|
}
|
|
|
|
[Fact]
|
|
public void UpsertRefusesAnIncompleteOrStrayMunitionPart()
|
|
{
|
|
using var cache = AetheriaStores.Open(Catalog, catalogWritable: true);
|
|
cache.Upsert(new VerseGrammar { Revision = 1 });
|
|
cache.Upsert(Munition("Whole", new LauncherData()));
|
|
|
|
var incomplete = Munition("Incomplete", new GuidedWeaponData());
|
|
incomplete.Behaviors.RemoveAll(b => b is MunitionThrusterData);
|
|
Assert.Contains(nameof(MunitionThrusterData), Assert.Throws<InvalidOperationException>(() => cache.Upsert(incomplete)).Message);
|
|
Assert.DoesNotContain(cache.GetAll<WeaponItemData>(), d => d.Name == "Incomplete");
|
|
|
|
var potion = new ConsumableItemData { Name = "Stray Potion" };
|
|
potion.Behaviors.Add(new MunitionControlData());
|
|
Assert.Contains(nameof(MunitionControlData), Assert.Throws<InvalidOperationException>(() => cache.Upsert(potion)).Message);
|
|
}
|
|
|
|
// The Longinus's own gear: two drives, a core, a cockpit and a sensor, so the ship has thrusters, a sensor and a plume
|
|
// for any munition contribution to be seen against.
|
|
private static readonly (string Name, int X, int Y)[] ShipGear =
|
|
{ ("Large Drive", 1, 0), ("Large Drive", 3, 0), ("Compact Reactor", 2, 4), ("Cockpit 2x2", 2, 6), ("Compact Sensor", 3, 10) };
|
|
|
|
// A Guided Missile Launcher fitted to a Longinus launcher hardpoint beside that gear, its lot's thruster role at the given quality.
|
|
private static EquippedItem Fit(CultCache cache, float thrusterQuality, out Ship ship)
|
|
{
|
|
var hull = cache.GetByName<HullData>("Longinus");
|
|
var gt = cache.GetByName<WeaponItemData>("Guided Missile Launcher");
|
|
var hardpoint = hull.Hardpoints.First(h => h.Type == HardpointType.Launcher && h.IsFilledBy(gt));
|
|
var settings = RestoredHullsTests.Settings();
|
|
var ledger = new ProvenanceLedger();
|
|
ledger.Lots[1] = new Lot { Origin = new Attributed(), Quality = .5f };
|
|
ledger.Lots[2] = new Lot { Origin = new Attributed(), Quality = .5f, Roles = new List<RoleFill> { new RoleFill { Role = "thruster", Quality = thrusterQuality } } };
|
|
var items = new ItemManager(cache, ledger, settings, _ => { });
|
|
var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test", Owner = null }, null);
|
|
ship = new Ship(items, zone, new EquippableItem { Data = cache.RefOf<ItemData>(hull), Durability = hull.Durability, Lot = 1 }, settings.DefaultEntitySettings);
|
|
foreach (var (name, x, y) in ShipGear)
|
|
{
|
|
var gear = cache.GetByName<EquippableItemData>(name);
|
|
Assert.True(ship.TryEquip(new EquippableItem { Data = cache.RefOf<ItemData>(gear), Durability = gear.Durability, Lot = 1 }, new int2(x, y)), name);
|
|
}
|
|
Assert.True(ship.TryEquip(new EquippableItem { Data = cache.RefOf<ItemData>(gt), Durability = gt.Durability, Lot = 2 }, hardpoint.Position));
|
|
zone.Admit(ship, piloted: false);
|
|
// Settle at the item's optimal temperature, as ConditionRatioTests does, so heat and wear read their identity and only
|
|
// the role's quality moves a stat.
|
|
var item = ship.Equipment.Single(e => e.Data.Name == "Guided Missile Launcher");
|
|
foreach (var cell in item.InsetShape.Coordinates) ship.Temperature[cell.x, cell.y] = gt.OptimalTemperature;
|
|
item.UpdatePerformance();
|
|
item.UpdatePerformance();
|
|
return item;
|
|
}
|
|
|
|
[Fact]
|
|
public void PartsReadThroughTheItem()
|
|
{
|
|
using var cache = RestoredHullsTests.OpenCatalog();
|
|
var design = cache.GetByName<WeaponItemData>("Guided Missile Launcher");
|
|
var poor = Fit(cache, .2f, out var poorShip);
|
|
var good = Fit(cache, .9f, out _);
|
|
|
|
var part = MunitionParts.Of<MunitionThrusterData>(poor);
|
|
Assert.Same(Part<MunitionThrusterData>(design), part);
|
|
Assert.Same(part, MunitionParts.Of<MunitionThrusterData>(good));
|
|
var low = poor.Evaluate(part.Acceleration);
|
|
var high = good.Evaluate(part.Acceleration);
|
|
Assert.True(low < high, $"role quality .2 reads {low}, .9 reads {high}");
|
|
Assert.InRange(low, part.Acceleration.Min, part.Acceleration.Max);
|
|
Assert.InRange(high, part.Acceleration.Min, part.Acceleration.Max);
|
|
Assert.True(poor.Evaluate(part.MaxSpeed) < good.Evaluate(part.MaxSpeed));
|
|
Assert.DoesNotContain(poor.Behaviors, b => b is Thruster);
|
|
Assert.Equal(ShipGear.Count(g => cache.GetByName<EquippableItemData>(g.Name).Behaviors.Any(b => b is ThrusterData)), poorShip.GetBehaviors<Thruster>().Count());
|
|
}
|
|
|
|
private static string ShipSums(Ship s)
|
|
{
|
|
var e = s.Envelope;
|
|
var channels = string.Join(",", Enum.GetValues(typeof(SignatureChannel)).Cast<SignatureChannel>().Select(c => $"{c}={s.VisibilitySources.Values.Where(v => v.Channel == c).Sum(v => v.Strength):0.####}"));
|
|
var sensors = string.Join(",", s.GetBehaviors<Sensor>().Select(b => b.Data.GetType().Name));
|
|
return $"env F{e.Forward:0.###} R{e.Reverse:0.###} L{e.Left:0.###} Ri{e.Right:0.###} CW{e.Clockwise:0.###} CCW{e.CounterClockwise:0.###} " +
|
|
$"vis={s.Visibility:0.####} sources={s.VisibilitySources.Count} {channels} thrusters={s.GetBehaviors<Thruster>().Count()} thrust={s.GetBehaviors<Thruster>().Sum(t => t.Thrust):0.###} sensors=[{sensors}]";
|
|
}
|
|
|
|
private static void ScaleParts(EquippableItemData design, float factor)
|
|
{
|
|
foreach (var part in design.Behaviors.OfType<MunitionPartData>())
|
|
foreach (var field in part.GetType().GetFields().Where(f => f.FieldType == typeof(PerformanceStat)))
|
|
{
|
|
var stat = (PerformanceStat) field.GetValue(part);
|
|
stat.Min *= factor;
|
|
stat.Max *= factor;
|
|
}
|
|
}
|
|
|
|
// A munition's thruster and seeker are not the ship's: with real drives and a sensor aboard, the ship's envelope, plume,
|
|
// signatures, thrusters and sensors do not move when the fitted launcher's part stats are a thousand times larger, while
|
|
// the value the game reads from the part does.
|
|
[Fact]
|
|
public void ShipSumsIgnoreTheFittedMunitionParts()
|
|
{
|
|
using var cache = RestoredHullsTests.OpenCatalog();
|
|
var design = cache.GetByName<WeaponItemData>("Guided Missile Launcher");
|
|
var part = Part<MunitionThrusterData>(design);
|
|
var item = Fit(cache, .5f, out var ship);
|
|
for (var i = 0; i < 5; i++) ship.Update(.1f);
|
|
var shipBefore = ShipSums(ship);
|
|
var partBefore = item.Evaluate(part.Acceleration);
|
|
Assert.NotEmpty(ship.GetBehaviors<Thruster>());
|
|
Assert.NotEmpty(ship.GetBehaviors<Sensor>());
|
|
|
|
ScaleParts(design, 1000f);
|
|
try
|
|
{
|
|
var scaledItem = Fit(cache, .5f, out var scaledShip);
|
|
for (var i = 0; i < 5; i++) scaledShip.Update(.1f);
|
|
Assert.True(scaledItem.Evaluate(part.Acceleration) > partBefore * 100, "the scaled record must move the value the game reads");
|
|
Assert.Equal(shipBefore, ShipSums(scaledShip));
|
|
}
|
|
finally { ScaleParts(design, .001f); }
|
|
}
|
|
}
|