From 38198b6e00a7706930b3613c5718aedbe4282c49 Mon Sep 17 00:00:00 2001
From: Meta Vens
Date: Sun, 18 Oct 2026 07:04:15 +0200
Subject: [PATCH 1/9] Lot.EffectiveQuality: the item's one quality, the mean of
min(part, assembly) over its fills
QualityForRole's unnamed, roleless and unfilled fallbacks read it. Builds.
Co-Authored-By: Claude Sonnet 5.5
---
Assets/Scripts/ServerShared/Provenance.cs | 20 +++++++---
.../AssemblyFromMakerTests.cs | 38 ++++++++++++++++---
tests/Aetheria.Shared.Tests/LoadoutTests.cs | 4 +-
3 files changed, 49 insertions(+), 13 deletions(-)
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/LoadoutTests.cs b/tests/Aetheria.Shared.Tests/LoadoutTests.cs
index 4efbd0b30..9fe71b898 100644
--- a/tests/Aetheria.Shared.Tests/LoadoutTests.cs
+++ b/tests/Aetheria.Shared.Tests/LoadoutTests.cs
@@ -490,7 +490,7 @@ 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.
[Fact]
public void StatsReadTheLot()
{
@@ -525,7 +525,7 @@ public sealed class LoadoutTests : IDisposable
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);
From 174bdaded13dbac621a16314091a337f1f7d88d0 Mon Sep 17 00:00:00 2001
From: Meta Vens
Date: Sun, 18 Oct 2026 07:09:31 +0200
Subject: [PATCH 2/9] Every reader of an item's quality reads
Lot.EffectiveQuality
Price, tier, equipped thermal and durability exponents, wear and the unequipped durability exponent now read the one effective quality; no reader keeps a path to the assembly alone. Tests pin each reader against a lot whose part sits below its assembly. Unverified until the Yggdrasil run.
Co-Authored-By: Claude Sonnet 5.5
---
Assets/Scripts/ServerShared/Entity.cs | 7 +-
Assets/Scripts/ServerShared/ItemData.cs | 2 +-
Assets/Scripts/ServerShared/ItemManager.cs | 10 +--
.../HeatResponseTests.cs | 24 ++++++-
tests/Aetheria.Shared.Tests/LoadoutTests.cs | 64 +++++++++++++++----
5 files changed, 86 insertions(+), 21 deletions(-)
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..a9619c61e 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;
}
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/tests/Aetheria.Shared.Tests/HeatResponseTests.cs b/tests/Aetheria.Shared.Tests/HeatResponseTests.cs
index 9711f91c1..6f1f768ee 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,26 @@ 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.
+ [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);
+ }
+
[Fact]
public void AFastSwingAcrossThePlateauStillWears()
{
diff --git a/tests/Aetheria.Shared.Tests/LoadoutTests.cs b/tests/Aetheria.Shared.Tests/LoadoutTests.cs
index 9fe71b898..528f5ad58 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 the effective quality .15, 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,8 +520,16 @@ 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 } }
+ });
var instance = (EquippableItem) items.CreateInstance(commonLot);
var rareInstance = (EquippableItem) items.CreateInstance(rareLot);
+ var cappedInstance = (EquippableItem) items.CreateInstance(cappedLot);
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 } } };
@@ -528,6 +537,7 @@ public sealed class LoadoutTests : IDisposable
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);
// Minting a second instance from an existing lot must not re-roll the lot's quality.
var secondInstance = (EquippableItem) items.CreateInstance(commonLot);
@@ -553,9 +563,10 @@ 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).
[Fact]
- public void GetPriceReadsLotQuality()
+ public void GetPriceReadsEffectiveQuality()
{
using var cache = Open();
var settings = RunSaveTests.TestSettings();
@@ -564,16 +575,24 @@ 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 low = (EquippableItem) items.CreateInstance(lowLot);
var high = (EquippableItem) items.CreateInstance(highLot);
+ var sleeper = (EquippableItem) items.CreateInstance(sleeperLot);
Assert.True(items.GetPrice(high) > items.GetPrice(low));
+ Assert.Equal(items.GetPrice(low), items.GetPrice(sleeper));
+ 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 +601,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 +633,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))
From 349a226c72adc571040f3fb9b6202d5f4b42f140 Mon Sep 17 00:00:00 2001
From: Meta Vens
Date: Sun, 18 Oct 2026 07:18:37 +0200
Subject: [PATCH 3/9] MismatchedDrivesHoldHeading stages its two drive lots at
.55 and .85
Heat now reads effective quality, which narrowed the Generated fit's drive spread below the test's 5 percent premise (probe: thermal exponents 1.227/.809 at base, 1.623/1.318 at head; NominalThrust ratio 1.0525 then 1.0436). The test fits the two Large Drives with lots staged at the low and high end of the makers' assembly range, chosen before any run, through the ship's own unequip and equip, and asserts the spread as a premise with its own message. Unverified until the Yggdrasil run.
Co-Authored-By: Claude Sonnet 5.5
---
.../ThrustAllocationTests.cs | 35 +++++++++++++++++--
1 file changed, 32 insertions(+), 3 deletions(-)
diff --git a/tests/Aetheria.Shared.Tests/ThrustAllocationTests.cs b/tests/Aetheria.Shared.Tests/ThrustAllocationTests.cs
index 068ec2f40..c1772695b 100644
--- a/tests/Aetheria.Shared.Tests/ThrustAllocationTests.cs
+++ b/tests/Aetheria.Shared.Tests/ThrustAllocationTests.cs
@@ -38,17 +38,46 @@ 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, so heat, thrust and the allocator stay the production path.
+ private void StageDriveLots(Ship ship)
+ {
+ 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));
+ }
+ }
+
[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]
From f3044718c17f9becc6fa0b68b21d2d1da0e2d246 Mon Sep 17 00:00:00 2001
From: Meta Vens
Date: Sun, 18 Oct 2026 07:22:04 +0200
Subject: [PATCH 4/9] Stage the drive lots with the ship deactivated: an active
entity refuses unequip and equip
Co-Authored-By: Claude Sonnet 5.5
---
tests/Aetheria.Shared.Tests/ThrustAllocationTests.cs | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/tests/Aetheria.Shared.Tests/ThrustAllocationTests.cs b/tests/Aetheria.Shared.Tests/ThrustAllocationTests.cs
index c1772695b..78cbcf368 100644
--- a/tests/Aetheria.Shared.Tests/ThrustAllocationTests.cs
+++ b/tests/Aetheria.Shared.Tests/ThrustAllocationTests.cs
@@ -40,9 +40,11 @@ public sealed partial class RunStartTests
// 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, so heat, thrust and the allocator stay the production path.
+ // 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 };
@@ -63,6 +65,7 @@ public sealed partial class RunStartTests
};
Assert.True(ship.TryEquip(staged, fitted.Position));
}
+ ship.Activate();
}
[Fact]
From d820e155ff1866139f73b8027bedc60c93b4be3a Mon Sep 17 00:00:00 2001
From: Meta Vens
Date: Sun, 18 Oct 2026 07:34:13 +0200
Subject: [PATCH 5/9] GetPrice test: a lot of two parts, one above its
assembly, prices as the mean of the capped parts
Kills the hand mutant that prices a lot at its best part (the single-fill sleeper could not tell max from mean). Builds.
Co-Authored-By: Claude Sonnet 5.5
---
tests/Aetheria.Shared.Tests/LoadoutTests.cs | 13 ++++++++++++-
1 file changed, 12 insertions(+), 1 deletion(-)
diff --git a/tests/Aetheria.Shared.Tests/LoadoutTests.cs b/tests/Aetheria.Shared.Tests/LoadoutTests.cs
index 528f5ad58..3280f3fbf 100644
--- a/tests/Aetheria.Shared.Tests/LoadoutTests.cs
+++ b/tests/Aetheria.Shared.Tests/LoadoutTests.cs
@@ -564,7 +564,8 @@ public sealed class LoadoutTests : IDisposable
}
// 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).
+ // 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 GetPriceReadsEffectiveQuality()
{
@@ -580,11 +581,21 @@ public sealed class LoadoutTests : IDisposable
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));
}
From b6075b776f10c7b685ef3a2707d8051c5ebe5622 Mon Sep 17 00:00:00 2001
From: Meta Vens
Date: Sun, 18 Oct 2026 10:27:46 +0200
Subject: [PATCH 6/9] Tier and wear tests pin the tolerance, the upgrade steps
and the direction of the quality term
Kills the Stryker survivors in the lines this cut touches that a quality-reading test can reach. Builds.
Co-Authored-By: Claude Sonnet 5.5
---
tests/Aetheria.Shared.Tests/HeatResponseTests.cs | 3 ++-
tests/Aetheria.Shared.Tests/LoadoutTests.cs | 7 +++++++
2 files changed, 9 insertions(+), 1 deletion(-)
diff --git a/tests/Aetheria.Shared.Tests/HeatResponseTests.cs b/tests/Aetheria.Shared.Tests/HeatResponseTests.cs
index 6f1f768ee..2873fc743 100644
--- a/tests/Aetheria.Shared.Tests/HeatResponseTests.cs
+++ b/tests/Aetheria.Shared.Tests/HeatResponseTests.cs
@@ -250,7 +250,7 @@ public sealed class HeatResponseTests : IDisposable
}
// 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.
+ // as a roleless .9 lot, which wears less (better quality wears less).
[Fact]
public void HeldOutsidePlateauWearReadsEffectiveQuality()
{
@@ -267,6 +267,7 @@ public sealed class HeatResponseTests : IDisposable
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]
diff --git a/tests/Aetheria.Shared.Tests/LoadoutTests.cs b/tests/Aetheria.Shared.Tests/LoadoutTests.cs
index 3280f3fbf..8abac0171 100644
--- a/tests/Aetheria.Shared.Tests/LoadoutTests.cs
+++ b/tests/Aetheria.Shared.Tests/LoadoutTests.cs
@@ -527,9 +527,12 @@ public sealed class LoadoutTests : IDisposable
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 } } };
@@ -538,6 +541,10 @@ public sealed class LoadoutTests : IDisposable
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);
+ Assert.Equal(0, items.GetTier(instance).upgrades);
// Minting a second instance from an existing lot must not re-roll the lot's quality.
var secondInstance = (EquippableItem) items.CreateInstance(commonLot);
From 7206c5fdf469e60d77b9b3ac137048c152cff2ea Mon Sep 17 00:00:00 2001
From: Meta Vens
Date: Sun, 18 Oct 2026 10:28:11 +0200
Subject: [PATCH 7/9] StatsReadTheLot: drop the upgrade count of the lot below
the first tier
Its effective quality .15 sits under Common's .2, which GetTier counts as -1; not a rule this test is for. Builds.
Co-Authored-By: Claude Sonnet 5.5
---
tests/Aetheria.Shared.Tests/LoadoutTests.cs | 1 -
1 file changed, 1 deletion(-)
diff --git a/tests/Aetheria.Shared.Tests/LoadoutTests.cs b/tests/Aetheria.Shared.Tests/LoadoutTests.cs
index 8abac0171..8321a4472 100644
--- a/tests/Aetheria.Shared.Tests/LoadoutTests.cs
+++ b/tests/Aetheria.Shared.Tests/LoadoutTests.cs
@@ -544,7 +544,6 @@ public sealed class LoadoutTests : IDisposable
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);
- Assert.Equal(0, items.GetTier(instance).upgrades);
// Minting a second instance from an existing lot must not re-roll the lot's quality.
var secondInstance = (EquippableItem) items.CreateInstance(commonLot);
From 8cc7f1bc85bcf0307b2db5cb25465fbb97be23a1 Mon Sep 17 00:00:00 2001
From: Meta Vens
Date: Sun, 18 Oct 2026 11:06:45 +0200
Subject: [PATCH 8/9] Pin GetTier: it hides an over-specced part, and its
tolerance and upgrade step at the shipped thresholds
Co-Authored-By: Claude Sonnet 5.5
---
tests/Aetheria.Shared.Tests/LoadoutTests.cs | 73 +++++++++++++++++++++
1 file changed, 73 insertions(+)
diff --git a/tests/Aetheria.Shared.Tests/LoadoutTests.cs b/tests/Aetheria.Shared.Tests/LoadoutTests.cs
index 8321a4472..5673b205d 100644
--- a/tests/Aetheria.Shared.Tests/LoadoutTests.cs
+++ b/tests/Aetheria.Shared.Tests/LoadoutTests.cs
@@ -551,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
From e95a36571961d1124b14d57a9a3535d7655abf48 Mon Sep 17 00:00:00 2001
From: Meta Vens
Date: Sun, 18 Oct 2026 11:06:46 +0200
Subject: [PATCH 9/9] ItemData role-validation comment: the fallback is the
effective quality
Co-Authored-By: Claude Sonnet 5.5
---
Assets/Scripts/ServerShared/ItemData.cs | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/Assets/Scripts/ServerShared/ItemData.cs b/Assets/Scripts/ServerShared/ItemData.cs
index a9619c61e..ca1484e36 100644
--- a/Assets/Scripts/ServerShared/ItemData.cs
+++ b/Assets/Scripts/ServerShared/ItemData.cs
@@ -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