Merge eureka/aetheria-release-quality-readers (head e95a3657)

Every reader of item quality reads Lot.EffectiveQuality (ruling effective-quality-mean-of-roles). Reports cut-quality-readers.h2, h3; Soul s1, s2 hold. Master's commits since base 70c7a497 are docs only, so the merged code equals the verified head.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-18 11:22:04 +02:00
commit 67517f407f
8 changed files with 261 additions and 37 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -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<RoleFill> { 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<RoleFill> { 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<RoleFill>() }.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<RoleFill>
{
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<RoleFill>() }.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.

View file

@ -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<Faction>("Maker"));
var hullData = cache.GetByName<HullData>("Skiff");
var thermalData = cache.GetByName<GearData>("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<ItemData>(cache.GetByName<GearData>("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()
{

View file

@ -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<RoleFill>()
});
var cappedLot = items.Lots.Add(new Lot
{
Design = cache.RefOf<ItemData>(lamp),
Origin = new Attributed(),
Quality = .6f,
Roles = new List<RoleFill> { 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<ItemData>(lamp), Origin = new Attributed(), Quality = .4995f, Roles = new List<RoleFill>() });
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>("GameplaySettings");
var tiers = settings.Tiers;
var items = new ItemManager(cache, new ProvenanceLedger(), settings, _ => { });
var lamp = cache.GetByName<GearData>("Lamp");
var assembly = tiers[3].Quality;
var sleeperLot = items.Lots.Add(new Lot
{
Design = cache.RefOf<ItemData>(lamp),
Origin = new Attributed(),
Quality = assembly,
Roles = new List<RoleFill>
{
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<ItemData>(lamp), Origin = new Attributed(), Quality = cappedMean, Roles = new List<RoleFill>() });
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>("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<GearData>("Lamp");
string TierAt(float quality) => items.GetTier((CraftedItemInstance) items.CreateInstance(items.Lots.Add(
new Lot { Design = cache.RefOf<ItemData>(lamp), Origin = new Attributed(), Quality = quality, Roles = new List<RoleFill>() }))).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>("GameplaySettings");
var items = new ItemManager(cache, new ProvenanceLedger(), settings, _ => { });
var lamp = cache.GetByName<GearData>("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<ItemData>(lamp), Origin = new Attributed(), Quality = quality, Roles = new List<RoleFill>() });
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<GearData>("Lamp");
var lowLot = items.Lots.Add(new Lot { Design = cache.RefOf<ItemData>(lamp), Origin = new Attributed(), Quality = .2f, Roles = new List<RoleFill>() });
var highLot = items.Lots.Add(new Lot { Design = cache.RefOf<ItemData>(lamp), Origin = new Attributed(), Quality = .8f, Roles = new List<RoleFill>() });
var sleeperLot = items.Lots.Add(new Lot
{
Design = cache.RefOf<ItemData>(lamp), Origin = new Attributed(), Quality = .8f,
Roles = new List<RoleFill> { new RoleFill { Role = "lens", Quality = .2f } }
});
var middleLot = items.Lots.Add(new Lot { Design = cache.RefOf<ItemData>(lamp), Origin = new Attributed(), Quality = .4f, Roles = new List<RoleFill>() });
var mixedLot = items.Lots.Add(new Lot
{
Design = cache.RefOf<ItemData>(lamp), Origin = new Attributed(), Quality = .6f,
Roles = new List<RoleFill> { 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<ItemData>(durable.Key), Origin = new Attributed(), Quality = .1f, Roles = new List<RoleFill>() });
var highLot = items.Lots.Add(new Lot { Design = new CultRecordRef<ItemData>(durable.Key), Origin = new Attributed(), Quality = .9f, Roles = new List<RoleFill>() });
var sleeperLot = items.Lots.Add(new Lot
{
Design = new CultRecordRef<ItemData>(durable.Key), Origin = new Attributed(), Quality = .9f,
Roles = new List<RoleFill> { 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<int> 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<ItemData>(lampData), Origin = new Attributed(), Quality = .9f,
Roles = new List<RoleFill> { 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))

View file

@ -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<RoleFill>()).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]