diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index 034e211bb..4a2b25402 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -2189,16 +2189,17 @@ public class EquippedItem : IStatContext Hardpoint = hardpoint; EquippableItem = item; Lot = ItemManager.GetLot(item); + var quality = Lot.EffectiveQuality(); Position = position; Conductivity = Data.Conductivity; ThermalExponent = lerp( ItemManager.GameplaySettings.ThermalQualityMin, ItemManager.GameplaySettings.ThermalQualityMax, - pow(Lot.Quality, ItemManager.GameplaySettings.ThermalQualityExponent)); + pow(quality, ItemManager.GameplaySettings.ThermalQualityExponent)); DurabilityExponent = lerp( ItemManager.GameplaySettings.DurabilityQualityMin, ItemManager.GameplaySettings.DurabilityQualityMax, - pow(Lot.Quality, ItemManager.GameplaySettings.DurabilityQualityExponent)); + pow(quality, ItemManager.GameplaySettings.DurabilityQualityExponent)); var hullData = itemManager.GetData(entity.Hull); InsetShape = hullData.Shape.Inset(Data.Shape, position, item.Rotation); if (Entity.Temperature != null) oldTemperature = Temperature; @@ -2369,7 +2370,7 @@ public class EquippedItem : IStatContext Entity.Resolver.InvalidateSource(this, StatSource.Durability); var performanceThreshold = Entity.Settings.ShutdownPerformance; Wear = (1 - pow(ThermalPerformance, - (1 - pow(Lot.Quality, ItemManager.GameplaySettings.QualityWearExponent)) * + (1 - pow(Lot.EffectiveQuality(), ItemManager.GameplaySettings.QualityWearExponent)) * ItemManager.GameplaySettings.ThermalWearExponent) + deltaTemp * ItemManager.GameplaySettings.DeltaTempWearExponent ) * Data.Durability / Data.ThermalResilience; diff --git a/Assets/Scripts/ServerShared/ItemData.cs b/Assets/Scripts/ServerShared/ItemData.cs index d7b2ee9b3..ca1484e36 100644 --- a/Assets/Scripts/ServerShared/ItemData.cs +++ b/Assets/Scripts/ServerShared/ItemData.cs @@ -688,7 +688,7 @@ public class StatTerm { [JsonProperty("source"), Key(0)] public StatSource Source; [JsonProperty("exponent"), Key(1)] public float Exponent; - // The design role whose part quality this term reads; unset reads the item's own workmanship quality. + // The design role whose part quality this term reads; unset reads the item's effective quality (Lot.EffectiveQuality). // Njordr states the same rule as a derivation over dimensions; this is that rule with one dimension. [Inspectable, JsonProperty("role"), Key(2)] public string Role; } @@ -888,7 +888,7 @@ public static class StatValidation } // Cut 7 (docs/stats-and-power-cut.md): "a stat naming a role its design lacks" must be refused, not silently - // fall back to the lot's own workmanship (Lot.QualityForRole already falls back for a role a lot never rolled + // fall back to the lot's effective quality (Lot.QualityForRole already falls back for a role a lot never rolled // -- that is the intended forward-compat path for an old lot meeting a newly-authored role. This is the other // direction: a StatTerm naming a role the design itself never declared is an authoring mistake, not a lot // predating the role, and must fail loudly rather than silently reading generic quality). Every PerformanceStat diff --git a/Assets/Scripts/ServerShared/ItemManager.cs b/Assets/Scripts/ServerShared/ItemManager.cs index 73e2c9592..12d45d101 100644 --- a/Assets/Scripts/ServerShared/ItemManager.cs +++ b/Assets/Scripts/ServerShared/ItemManager.cs @@ -82,7 +82,7 @@ public class ItemManager return result; } - // The unequipped case: quality and durability read live off the item and its lot; heat and modifiers do not + // The unequipped case: quality (the lot's effective quality) and durability read live off the item and its lot; heat and modifiers do not // exist without an entity, so they resolve as the identity for their term rather than a bespoke formula. private sealed class UnequippedStatContext : IStatContext { @@ -99,7 +99,7 @@ public class ItemManager _durabilityExponent = lerp( items.GameplaySettings.DurabilityQualityMin, items.GameplaySettings.DurabilityQualityMax, - pow(Lot.Quality, items.GameplaySettings.DurabilityQualityExponent)); + pow(Lot.EffectiveQuality(), items.GameplaySettings.DurabilityQualityExponent)); } public float HeatFactor(float exponent) => 1f; @@ -111,10 +111,11 @@ public class ItemManager public float ConstantModifier(PerformanceStat stat) => 0f; } + // Price follows effective quality (price-follows-effective-quality), so a sleeper's over-specced parts do not show in it. public int GetPrice(CraftedItemInstance item) { var data = GetData(item); - return (int) (GameplaySettings.QualityPriceModifier.Evaluate(GetLot(item).Quality) * data.Price); + return (int) (GameplaySettings.QualityPriceModifier.Evaluate(GetLot(item).EffectiveQuality()) * data.Price); } // Builds one of a manufacturer's branded products (quality-is-assembly-over-supply-chain): the unit's own quality is its @@ -222,9 +223,10 @@ public class ItemManager public string Title(ItemInstance item) => (item is CraftedItemInstance crafted ? Brand(crafted).Product?.Name : null) ?? GetData(item).Name; + // The tier name and colour follow price, so they hide the same sleepers. public (RarityTier tier, int upgrades) GetTier(CraftedItemInstance item) { - var quality = GetLot(item).Quality; + var quality = GetLot(item).EffectiveQuality(); var tier = GameplaySettings.Tiers[0]; foreach (var t in GameplaySettings.Tiers) if (quality + .001f > t.Quality) diff --git a/Assets/Scripts/ServerShared/Provenance.cs b/Assets/Scripts/ServerShared/Provenance.cs index adf83c308..4121332d6 100644 --- a/Assets/Scripts/ServerShared/Provenance.cs +++ b/Assets/Scripts/ServerShared/Provenance.cs @@ -58,7 +58,7 @@ public class ProvenanceLedger } } -// One lot: the design it built, who made it and from what, its rolled workmanship, how well each design role +// One lot: the design it built, who made it and from what, its final assembly quality, how well each design role // was filled, and the branded product it was made as. Immutable once minted; instances carry a LotId, never a copy. [MessagePackObject] public class Lot @@ -74,18 +74,28 @@ public class Lot // The quality a stat reads for one of the design's roles: the part's, capped by the lot's own final assembly // (quality-lean-assembly-hard-ceiling), so a part better than its assembly is worth the assembly. An unnamed role, a - // design without roles, and a lot minted before roles existed all fall back to the lot's own workmanship. + // design without roles, and a role without a fill read the item's effective quality. public float QualityForRole(string role) { - if (string.IsNullOrEmpty(role) || Roles == null) return Quality; + if (string.IsNullOrEmpty(role) || Roles == null) return EffectiveQuality(); foreach (var fill in Roles) if (fill.Role == role) return Math.Min(fill.Quality, Quality); - return Quality; + return EffectiveQuality(); + } + + // The item's one quality (effective-quality-mean-of-roles): the mean over its role fills of the part capped by the final + // assembly. Price, tier, heat, durability and wear all read it; a roleless lot reads its assembly. + public float EffectiveQuality() + { + if (Roles == null || Roles.Count == 0) return Quality; + var sum = 0f; + foreach (var fill in Roles) sum += Math.Min(fill.Quality, Quality); + return sum / Roles.Count; } } // One filled slot: the design's role name and how good the part that went into it turned out to be. Stats read it through -// Lot.QualityForRole, capped by the lot's assembly; the raw value is kept for disassembly (named-components-after-demo). +// Lot.QualityForRole and Lot.EffectiveQuality, capped by the lot's assembly; the raw value is kept for disassembly (named-components-after-demo). [MessagePackObject] public class RoleFill { diff --git a/tests/Aetheria.Shared.Tests/AssemblyFromMakerTests.cs b/tests/Aetheria.Shared.Tests/AssemblyFromMakerTests.cs index efc1e1b3a..301a67cd1 100644 --- a/tests/Aetheria.Shared.Tests/AssemblyFromMakerTests.cs +++ b/tests/Aetheria.Shared.Tests/AssemblyFromMakerTests.cs @@ -184,15 +184,41 @@ public sealed class AssemblyFromMakerTests : IDisposable Assert.All(Mint(tight, 50), q => Assert.Equal(.5f, q, 3)); } - // The fallbacks still read the assembly: an unnamed role, an unrolled role, and a lot with no roles. + // The fallbacks read the item's effective quality: an unnamed role and an unfilled role on a lot whose fills sit below and + // above its assembly (lens .3 -> .3, frame .9 -> .6, mean .45), and a lot with no roles reading its assembly. A found + // fill still reads its own capped part. [Fact] - public void FallbacksReadTheAssembly() + public void FallbacksReadEffectiveQuality() { - var lot = new Lot { Quality = .4f, Roles = new List { new RoleFill { Role = "lens", Quality = .9f } } }; - Assert.Equal(.4f, lot.QualityForRole(null), 3); - Assert.Equal(.4f, lot.QualityForRole(""), 3); - Assert.Equal(.4f, lot.QualityForRole("ghost"), 3); + var lot = new Lot + { + Quality = .6f, + Roles = new List { new RoleFill { Role = "lens", Quality = .3f }, new RoleFill { Role = "frame", Quality = .9f } } + }; + Assert.Equal(.45f, lot.QualityForRole(null), 3); + Assert.Equal(.45f, lot.QualityForRole(""), 3); + Assert.Equal(.45f, lot.QualityForRole("ghost"), 3); + Assert.Equal(.3f, lot.QualityForRole("lens"), 3); Assert.Equal(.4f, new Lot { Quality = .4f }.QualityForRole("lens"), 3); + Assert.Equal(.4f, new Lot { Quality = .4f, Roles = new List() }.QualityForRole("lens"), 3); + } + + // The effective quality is the mean over the fills of min(part, assembly): assembly .6 with parts .9, .3, .5 reads + // (.6 + .3 + .5) / 3, not the raw mean .5667, the min .3, the assembly .6 or the max. A lot with no fills reads its assembly. + [Fact] + public void EffectiveQualityIsTheMeanOfCappedFills() + { + var lot = new Lot + { + Quality = .6f, + Roles = new List + { + new RoleFill { Role = "a", Quality = .9f }, new RoleFill { Role = "b", Quality = .3f }, new RoleFill { Role = "c", Quality = .5f } + } + }; + Assert.Equal(.4667f, lot.EffectiveQuality(), 3); + Assert.Equal(.6f, new Lot { Quality = .6f }.EffectiveQuality(), 3); + Assert.Equal(.6f, new Lot { Quality = .6f, Roles = new List() }.EffectiveQuality(), 3); } // A Quality term on a role with part .9 and assembly .4 evaluates as if quality were .4, on a real minted instance. diff --git a/tests/Aetheria.Shared.Tests/HeatResponseTests.cs b/tests/Aetheria.Shared.Tests/HeatResponseTests.cs index 9711f91c1..2873fc743 100644 --- a/tests/Aetheria.Shared.Tests/HeatResponseTests.cs +++ b/tests/Aetheria.Shared.Tests/HeatResponseTests.cs @@ -26,14 +26,14 @@ public sealed class HeatResponseTests : IDisposable // A hull plus one thermal gear design, equipped and settled at the given start temperature (two // UpdatePerformance calls so deltaTemp reads 0 before the test drives its own change). - private EquippedItem BuildEquippedAt(CultCache cache, ItemManager items, float startTemperature, out Ship ship) + private EquippedItem BuildEquippedAt(CultCache cache, ItemManager items, float startTemperature, out Ship ship, Lot gearLot = null) { var maker = cache.RefOf(cache.GetByName("Maker")); var hullData = cache.GetByName("Skiff"); var thermalData = cache.GetByName("Thermal"); var hullItem = (EquippableItem) items.CreateInstance(items.CreateLot(hullData, maker, .5f)); ship = new Ship(items, null, hullItem, new EntitySettings()); - var gearItem = (EquippableItem) items.CreateInstance(items.CreateLot(thermalData, maker, .5f)); + var gearItem = (EquippableItem) items.CreateInstance(gearLot != null ? items.Lots.Add(gearLot) : items.CreateLot(thermalData, maker, .5f)); Assert.True(ship.TryEquip(gearItem, HardpointCell)); var equipped = ship.Equipment.Single(e => e.Data is GearData); foreach (var cell in equipped.InsetShape.Coordinates) ship.Temperature[cell.x, cell.y] = startTemperature; @@ -249,6 +249,27 @@ public sealed class HeatResponseTests : IDisposable Assert.True(equipped.Wear > 0f); } + // Wear's quality term reads the lot's effective quality: a .9 lot whose one part is .1 wears as a roleless .1 lot, and not + // as a roleless .9 lot, which wears less (better quality wears less). + [Fact] + public void HeldOutsidePlateauWearReadsEffectiveQuality() + { + using var cache = OpenCatalogWithThermalGear(); + var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { }); + var thermal = cache.RefOf(cache.GetByName("Thermal")); + Lot Gear(float assembly, params float[] parts) => new Lot + { + Design = thermal, Origin = new Attributed(), Quality = assembly, + Roles = parts.Select(p => new RoleFill { Role = "core", Quality = p }).ToList() + }; + float WearAt10(Lot lot) => BuildEquippedAt(cache, items, 10f, out _, lot).Wear; + + var sleeper = WearAt10(Gear(.9f, .1f)); + Assert.Equal(WearAt10(Gear(.1f)), sleeper, 5); + Assert.NotEqual(WearAt10(Gear(.9f)), sleeper, 5); + Assert.True(WearAt10(Gear(.9f)) < sleeper); + } + [Fact] public void AFastSwingAcrossThePlateauStillWears() { diff --git a/tests/Aetheria.Shared.Tests/LoadoutTests.cs b/tests/Aetheria.Shared.Tests/LoadoutTests.cs index 4efbd0b30..5673b205d 100644 --- a/tests/Aetheria.Shared.Tests/LoadoutTests.cs +++ b/tests/Aetheria.Shared.Tests/LoadoutTests.cs @@ -490,7 +490,8 @@ public sealed class LoadoutTests : IDisposable } // A Lamp lot with Quality .2 and one role fill (lens .15, under the assembly): a stat reading that role gets .15, one - // reading none gets .2, and GetTier reads .2. + // reading none gets the effective quality .15, and GetTier reads .2. A lot of + // assembly .6 with a lens of .3 is Common, not Rare: the tier follows its effective quality. [Fact] public void StatsReadTheLot() { @@ -519,15 +520,30 @@ public sealed class LoadoutTests : IDisposable Quality = .6f, Roles = new List() }); + var cappedLot = items.Lots.Add(new Lot + { + Design = cache.RefOf(lamp), + Origin = new Attributed(), + Quality = .6f, + Roles = new List { new RoleFill { Role = "lens", Quality = .3f } } + }); + // Just under the Rare threshold, inside GetTier's .001 tolerance. + var nearRareLot = items.Lots.Add(new Lot { Design = cache.RefOf(lamp), Origin = new Attributed(), Quality = .4995f, Roles = new List() }); var instance = (EquippableItem) items.CreateInstance(commonLot); var rareInstance = (EquippableItem) items.CreateInstance(rareLot); + var cappedInstance = (EquippableItem) items.CreateInstance(cappedLot); + var nearRareInstance = (EquippableItem) items.CreateInstance(nearRareLot); var withRole = new PerformanceStat { Min = 0, Max = 1, Terms = { new StatTerm { Source = StatSource.Quality, Exponent = 1, Role = "lens" } } }; var noRole = new PerformanceStat { Min = 0, Max = 1, Terms = { new StatTerm { Source = StatSource.Quality, Exponent = 1 } } }; Assert.Equal(.15f, items.Evaluate(withRole, instance), 3); - Assert.Equal(.2f, items.Evaluate(noRole, instance), 3); + Assert.Equal(.15f, items.Evaluate(noRole, instance), 3); Assert.Equal(.2f, items.GetTier(instance).tier.Quality, 3); Assert.Equal(.5f, items.GetTier(rareInstance).tier.Quality, 3); + Assert.Equal(.2f, items.GetTier(cappedInstance).tier.Quality, 3); + Assert.Equal(.5f, items.GetTier(nearRareInstance).tier.Quality, 3); + // Upgrades count the steps of .05 above the tier: the capped lot's effective .3 is two above Common's .2. + Assert.Equal(2, items.GetTier(cappedInstance).upgrades); // Minting a second instance from an existing lot must not re-roll the lot's quality. var secondInstance = (EquippableItem) items.CreateInstance(commonLot); @@ -535,6 +551,79 @@ public sealed class LoadoutTests : IDisposable Assert.Equal(.2f, items.GetTier(secondInstance).tier.Quality, 3); } + // The shipped tiers (Settings.asset) band a lot's effective quality, so the tier hides a sleeper whose part sits above + // its assembly (price-follows-effective-quality). Assembly at the fourth threshold, parts at the top and bottom + // thresholds: the capped mean falls in the second band, where the uncapped part mean reaches the third, the assembly + // alone the fourth and the best part the fifth. + [Fact] + public void GetTierHidesAnOverSpecPart() + { + using var cache = Open(); + var settings = AuthoredSettings.Load(RunStartTests.FindRepoRoot()).Read("GameplaySettings"); + var tiers = settings.Tiers; + var items = new ItemManager(cache, new ProvenanceLedger(), settings, _ => { }); + var lamp = cache.GetByName("Lamp"); + var assembly = tiers[3].Quality; + var sleeperLot = items.Lots.Add(new Lot + { + Design = cache.RefOf(lamp), + Origin = new Attributed(), + Quality = assembly, + Roles = new List + { + new RoleFill { Role = "lens", Quality = tiers[4].Quality }, + new RoleFill { Role = "frame", Quality = tiers[0].Quality } + } + }); + var cappedMean = (assembly + tiers[0].Quality) / 2f; + var rolelessLot = items.Lots.Add(new Lot { Design = cache.RefOf(lamp), Origin = new Attributed(), Quality = cappedMean, Roles = new List() }); + + var sleeper = items.GetTier((CraftedItemInstance) items.CreateInstance(sleeperLot)); + var roleless = items.GetTier((CraftedItemInstance) items.CreateInstance(rolelessLot)); + Assert.Equal(tiers[1].Name, sleeper.tier.Name); + Assert.Equal(roleless.tier.Name, sleeper.tier.Name); + Assert.Equal(roleless.upgrades, sleeper.upgrades); + } + + // The tolerance is open: a quality a full .001 under a threshold stays in the tier below, one inside it reaches the + // threshold's tier. Upgrades count steps of .0499 above the tier's threshold. Every value comes from the shipped tiers. + [Theory] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + public void GetTierToleranceIsOpenAtEveryShippedThreshold(int index) + { + using var cache = Open(); + var settings = AuthoredSettings.Load(RunStartTests.FindRepoRoot()).Read("GameplaySettings"); + var tiers = settings.Tiers; + Assert.True(tiers.Length > index, "premise: the shipped asset carries this tier"); + var items = new ItemManager(cache, new ProvenanceLedger(), settings, _ => { }); + var lamp = cache.GetByName("Lamp"); + string TierAt(float quality) => items.GetTier((CraftedItemInstance) items.CreateInstance(items.Lots.Add( + new Lot { Design = cache.RefOf(lamp), Origin = new Attributed(), Quality = quality, Roles = new List() }))).tier.Name; + + var threshold = tiers[index].Quality; + Assert.Equal(tiers[index - 1].Name, TierAt(threshold - .001f)); + Assert.Equal(tiers[index].Name, TierAt(threshold - .0005f)); + Assert.Equal(tiers[index].Name, TierAt(threshold)); + } + + [Fact] + public void GetTierUpgradesAreStepsOfFortyNineNinetyNinePerTenThousand() + { + using var cache = Open(); + var settings = AuthoredSettings.Load(RunStartTests.FindRepoRoot()).Read("GameplaySettings"); + var items = new ItemManager(cache, new ProvenanceLedger(), settings, _ => { }); + var lamp = cache.GetByName("Lamp"); + // Two steps and a hair above the lowest threshold: the step width decides whether that counts as two or one. + var quality = settings.Tiers[0].Quality + 2 * .0499f + .0001f; + var lot = items.Lots.Add(new Lot { Design = cache.RefOf(lamp), Origin = new Attributed(), Quality = quality, Roles = new List() }); + var result = items.GetTier((CraftedItemInstance) items.CreateInstance(lot)); + Assert.Equal(settings.Tiers[0].Name, result.tier.Name); + Assert.Equal(2, result.upgrades); + } + // S7 (docs/stats-and-power-cut.md Cut 1 findings): a Quality term must raise the lot's quality to its own // declared Exponent, not always to 1. StatsReadTheLot uses Exponent = 1 throughout, which cannot distinguish // "read the term's exponent" from "always use 1" -- pow(x, 1) == x either way. Exponent = 2 here can: a @@ -553,9 +642,11 @@ public sealed class LoadoutTests : IDisposable Assert.Equal(.25f, items.Evaluate(squared, instance), 3); } - // F4: GetPrice reads the lot's quality through GameplaySettings.QualityPriceModifier. + // F4: GetPrice reads the lot's effective quality through GameplaySettings.QualityPriceModifier: a lot of assembly .8 whose + // one part is .2 prices exactly as a roleless .2 lot (price-follows-effective-quality), and a lot of assembly .6 with + // parts .9 and .2 (capped to .6 and .2) prices as a roleless .4 lot: the mean of the capped parts, not the best part. [Fact] - public void GetPriceReadsLotQuality() + public void GetPriceReadsEffectiveQuality() { using var cache = Open(); var settings = RunSaveTests.TestSettings(); @@ -564,16 +655,34 @@ public sealed class LoadoutTests : IDisposable var lamp = cache.GetByName("Lamp"); var lowLot = items.Lots.Add(new Lot { Design = cache.RefOf(lamp), Origin = new Attributed(), Quality = .2f, Roles = new List() }); var highLot = items.Lots.Add(new Lot { Design = cache.RefOf(lamp), Origin = new Attributed(), Quality = .8f, Roles = new List() }); + var sleeperLot = items.Lots.Add(new Lot + { + Design = cache.RefOf(lamp), Origin = new Attributed(), Quality = .8f, + Roles = new List { new RoleFill { Role = "lens", Quality = .2f } } + }); + var middleLot = items.Lots.Add(new Lot { Design = cache.RefOf(lamp), Origin = new Attributed(), Quality = .4f, Roles = new List() }); + var mixedLot = items.Lots.Add(new Lot + { + Design = cache.RefOf(lamp), Origin = new Attributed(), Quality = .6f, + Roles = new List { new RoleFill { Role = "lens", Quality = .9f }, new RoleFill { Role = "frame", Quality = .2f } } + }); var low = (EquippableItem) items.CreateInstance(lowLot); var high = (EquippableItem) items.CreateInstance(highLot); + var sleeper = (EquippableItem) items.CreateInstance(sleeperLot); + var middle = (EquippableItem) items.CreateInstance(middleLot); + var mixed = (EquippableItem) items.CreateInstance(mixedLot); Assert.True(items.GetPrice(high) > items.GetPrice(low)); + Assert.Equal(items.GetPrice(low), items.GetPrice(sleeper)); + Assert.Equal(items.GetPrice(middle), items.GetPrice(mixed)); + Assert.True(items.GetPrice(middle) > items.GetPrice(low)); + Assert.True(items.GetPrice(sleeper) < items.GetPrice(high)); } - // F4: the durability exponent path in ItemManager.Evaluate reads the lot's quality (GameplaySettings. + // F4: the durability exponent path in ItemManager.Evaluate reads the lot's effective quality (GameplaySettings. // DurabilityQuality{Min,Max,Exponent}), independently of the quality-for-role term (absent here -- the - // stat declares only a Durability term). + // stat declares only a Durability term): a .9 lot whose one part is .1 evaluates as a roleless .1 lot. [Fact] - public void EvaluateDurabilityExponentReadsLotQuality() + public void EvaluateDurabilityExponentReadsEffectiveQuality() { using var cache = AetheriaStores.Open(Catalog, catalogWritable: true); var durable = cache.Upsert(new GearData { Name = "Durable", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Price = 10, Durability = 100 }); @@ -582,19 +691,29 @@ public sealed class LoadoutTests : IDisposable var items = new ItemManager(cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); var lowLot = items.Lots.Add(new Lot { Design = new CultRecordRef(durable.Key), Origin = new Attributed(), Quality = .1f, Roles = new List() }); var highLot = items.Lots.Add(new Lot { Design = new CultRecordRef(durable.Key), Origin = new Attributed(), Quality = .9f, Roles = new List() }); + var sleeperLot = items.Lots.Add(new Lot + { + Design = new CultRecordRef(durable.Key), Origin = new Attributed(), Quality = .9f, + Roles = new List { new RoleFill { Role = "lens", Quality = .1f } } + }); var low = (EquippableItem) items.CreateInstance(lowLot); var high = (EquippableItem) items.CreateInstance(highLot); + var sleeper = (EquippableItem) items.CreateInstance(sleeperLot); low.Durability = items.GetData(low).Durability / 2; high.Durability = items.GetData(high).Durability / 2; + sleeper.Durability = items.GetData(sleeper).Durability / 2; var stat = new PerformanceStat { Min = 0, Max = 1, Terms = { new StatTerm { Source = StatSource.Durability, Exponent = 1 } } }; Assert.NotEqual(items.Evaluate(stat, low), items.Evaluate(stat, high)); + Assert.Equal(items.Evaluate(stat, low), items.Evaluate(stat, sleeper), 3); + Assert.NotEqual(items.Evaluate(stat, high), items.Evaluate(stat, sleeper), 3); } - // F4: EquippedItem's thermal exponent (Entity.cs's EquippedItem constructor) reads the lot's quality - // (GameplaySettings.ThermalQuality{Min,Max,Exponent}), through a real equip on a live entity. + // F4: EquippedItem's thermal and durability exponents (Entity.cs's EquippedItem constructor) read the lot's effective + // quality (GameplaySettings.{Thermal,Durability}Quality{Min,Max,Exponent}), through a real equip on a live entity: a .9 + // lot whose one part is .1 equips as a roleless .1 lot. [Fact] - public void EquippedItemThermalExponentReadsLotQuality() + public void EquippedItemExponentsReadEffectiveQuality() { using var cache = Open(); var items = new ItemManager(cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); @@ -604,18 +723,31 @@ public sealed class LoadoutTests : IDisposable // TestSettings has one tier, so materializing through a Loadout always rolls the same quality; mint the // gear's lot directly at a chosen quality and equip it onto a bare ship instead. - Ship BuildWithGearQuality(float quality) + Ship BuildWithGear(Func mintGear) { var hullItem = (EquippableItem) items.CreateInstance(items.CreateLot(hullData, maker, .5f)); var ship = new Ship(items, null, hullItem, new EntitySettings()); - var gearItem = (EquippableItem) items.CreateInstance(items.CreateLot(lampData, maker, quality)); + var gearItem = (EquippableItem) items.CreateInstance(mintGear()); Assert.True(ship.TryEquip(gearItem, HardpointCell)); return ship; } + Ship BuildWithGearQuality(float quality) => BuildWithGear(() => items.CreateLot(lampData, maker, quality)); + // The gear is a lot added directly: assembly .9, one part at .1. + Ship BuildWithSleeperGear() => BuildWithGear(() => items.Lots.Add(new Lot + { + Design = cache.RefOf(lampData), Origin = new Attributed(), Quality = .9f, + Roles = new List { new RoleFill { Role = "lens", Quality = .1f } } + })); var lowEquipped = BuildWithGearQuality(.1f).Equipment.Single(e => e.Data is GearData); var highEquipped = BuildWithGearQuality(.9f).Equipment.Single(e => e.Data is GearData); + var sleeperEquipped = BuildWithSleeperGear().Equipment.Single(e => e.Data is GearData); Assert.NotEqual(lowEquipped.ThermalExponent, highEquipped.ThermalExponent); + Assert.NotEqual(lowEquipped.DurabilityExponent, highEquipped.DurabilityExponent); + Assert.Equal(lowEquipped.ThermalExponent, sleeperEquipped.ThermalExponent, 5); + Assert.Equal(lowEquipped.DurabilityExponent, sleeperEquipped.DurabilityExponent, 5); + Assert.NotEqual(highEquipped.ThermalExponent, sleeperEquipped.ThermalExponent, 5); + Assert.NotEqual(highEquipped.DurabilityExponent, sleeperEquipped.DurabilityExponent, 5); } // Cut 0 (docs/stats-and-power-cut.md): the collapsed evaluation path (PerformanceStat.Evaluate(IStatContext)) diff --git a/tests/Aetheria.Shared.Tests/ThrustAllocationTests.cs b/tests/Aetheria.Shared.Tests/ThrustAllocationTests.cs index 068ec2f40..78cbcf368 100644 --- a/tests/Aetheria.Shared.Tests/ThrustAllocationTests.cs +++ b/tests/Aetheria.Shared.Tests/ThrustAllocationTests.cs @@ -38,17 +38,49 @@ public sealed partial class RunStartTests return degrees(SignedTurn(before, ship.Direction)); } + // The two Large Drives, refitted with lots staged at the low and high end of the makers' assembly range (.55 and .85, each + // with its parts at its assembly), so their effective qualities differ by construction. The lots are fitted through the + // ship's own unequip and equip (an active entity refuses both, so it is deactivated for the refit and reactivated), so + // heat, thrust and the allocator stay the production path. + private void StageDriveLots(Ship ship) + { + ship.Deactivate(); + var drives = AllocDrives(ship).OrderBy(d => d.Item.Position.x).ToArray(); + Assert.Equal(2, drives.Length); + var assemblies = new[] { .55f, .85f }; + for (var i = 0; i < drives.Length; i++) + { + var fitted = drives[i].Item; + var unit = fitted.EquippableItem; + var lot = _items.GetLot(unit); + Assert.NotNull(ship.TryUnequip(fitted)); + var staged = new EquippableItem + { + Data = unit.Data, Durability = unit.Durability, Rotation = unit.Rotation, + Lot = _items.Lots.Add(new Lot + { + Design = lot.Design, Origin = lot.Origin, Product = lot.Product, Quality = assemblies[i], + Roles = (lot.Roles ?? new List()).Select(r => new RoleFill { Role = r.Role, Quality = assemblies[i] }).ToList() + }) + }; + Assert.True(ship.TryEquip(staged, fitted.Position)); + } + ship.Activate(); + } + [Fact] public void MismatchedDrivesHoldHeading() { var (_, player) = AllocDuel(); + StageDriveLots(player); var drives = AllocDrives(player); player.MovementDirection = float2(0, 1); player.Turn = 0; - Assert.True(abs(AllocFly(player, 120)) < .1f); + var heading = AllocFly(player, 120); // The drives start alike and heat apart as they fire: by now they are mismatched and both pushing. - Assert.True(drives.Length >= 2 && drives[^1].NominalThrust > drives[0].NominalThrust * 1.05f, "fixture: the drives are mismatched"); - Assert.All(drives, d => Assert.True(d.Axis > .5f, "fixture: the drives are pushing")); + Assert.True(drives.Length >= 2 && drives[^1].NominalThrust > drives[0].NominalThrust * 1.05f, "premise: the drives' NominalThrust spread reaches 5 percent"); + Assert.All(drives, d => Assert.True(d.Axis > .5f, "premise: the drives are pushing")); + Assert.True(abs(heading) < .1f); } [Theory]