Aetheria/tests/Aetheria.Shared.Tests/RoleAuthoringTests.cs
soul cc0c164574 determinism-ledger: every ItemManager states its seed; scatter on the zone's dice
- ItemManager(uint seed, ...) refuses seed 0 and seeds Random from it.
- RunStart.ItemSeed(galaxy, step) is public (formula unchanged), refuses a
  zero result; named steps ArenaStep, StageStep, ContinueStep and NoRunSeed.
- Continue seeds the item stream from the saved run's seed (ContinueStep);
  a scene with no galaxy uses NoRunSeed.
- Zone's unpack scatter draws SimulationDice.For(CombatSeed, ScatterStream,
  1-based pack ordinal), never the item stream.
- Loadout's scratch manager and AetherDb's catalog command take fixed seeds;
  every test and Editor construction gains 1u (mechanical rewrite).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 19:01:41 +02:00

119 lines
5.8 KiB
C#

using System;
using System.IO;
using System.Linq;
using CultMath;
using GameCult.Caching;
using Xunit;
// Cut 7 (docs/stats-and-power-cut.md): designs declare roles, stats name the role they read, and products author
// per-role quality. Two rules pinned here that StatsReadTheLot (LoadoutTests.cs) and CreateLotFillsRolesFromProductSpread
// (LoadoutTests.cs) do not already cover: a stat naming a role its design lacks is refused at Upsert, and two
// products of one design with different role means resolve to different values through the same stat.
public sealed class RoleAuthoringTests : IDisposable
{
private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-roles-" + Guid.NewGuid().ToString("N"));
private string Catalog => Path.Combine(_root, "Aetheria.cc");
public RoleAuthoringTests() => Directory.CreateDirectory(_root);
public void Dispose() => Directory.Delete(_root, true);
private GameplaySettings Settings() => new GameplaySettings
{
DefaultEntitySettings = new EntitySettings(),
Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Color = new float3(1, 1, 1) } },
QualityPriceModifier = new ExponentialLerp()
};
// The rule the ruling names directly: "validation already refuses a stat naming a role its design lacks."
// A term reading StatSource.Quality with Role "ghost" on a design that declares no roles at all must not
// reach disk -- silently falling back to Lot.Quality (Lot.QualityForRole's own behaviour for an unmatched
// role) would hide the authoring mistake instead of naming it.
[Fact]
public void UpsertRefusesAStatNamingARoleItsDesignLacks()
{
using var cache = AetheriaStores.Open(Catalog, catalogWritable: true);
var ghost = new GearData
{
Name = "Ghost Lamp", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Price = 10,
Behaviors =
{
new EnergyDrawData
{
EnergyDraw = new PerformanceStat
{
Min = 0, Max = 1,
Terms = { new StatTerm { Source = StatSource.Quality, Exponent = 1, Role = "ghost" } }
}
}
}
};
var ex = Assert.Throws<InvalidOperationException>(() => cache.Upsert(ghost));
Assert.Contains("ghost", ex.Message);
}
// The positive control for the same rule: a stat naming a role the design DOES declare must not throw --
// otherwise UpsertRefusesAStatNamingARoleItsDesignLacks could be passing because Upsert refuses every
// role-bearing stat, not because it distinguishes declared from undeclared.
[Fact]
public void UpsertAcceptsAStatNamingADeclaredRole()
{
using var cache = AetheriaStores.Open(Catalog, catalogWritable: true);
var lensed = new GearData
{
Name = "Lensed Lamp", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Price = 10,
Roles = { new ItemRole { Name = "lens" } },
Behaviors =
{
new EnergyDrawData
{
EnergyDraw = new PerformanceStat
{
Min = 0, Max = 1,
Terms = { new StatTerm { Source = StatSource.Quality, Exponent = 1, Role = "lens" } }
}
}
}
};
cache.Upsert(lensed); // must not throw
}
// F4/Cut 7: "a market segment is a second product with a role raised" -- two products of the same design,
// differing only in one role's Mean, must mint lots that resolve a role-termed stat to different values.
// Zero StandardDeviation on both keeps the roll deterministic, so this cannot pass by coincidence.
[Fact]
public void TwoProductsOfOneDesignWithDifferentRoleMeansResolveDifferentValues()
{
using var cache = AetheriaStores.Open(Catalog, catalogWritable: true);
var makerRef = cache.Upsert(new Faction { Name = "Maker", ShortName = "MKR", Assembly = new AssemblyDistribution { Mean = 1f, StandardDeviation = 0 } });
var lensGear = cache.Upsert(new GearData
{
Name = "Lensed", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Price = 10,
Roles = { new ItemRole { Name = "lens" } }
});
var cheapProduct = cache.Upsert(new FactionProductData
{
Name = "Lensed Economy", Design = new CultRecordRef<CraftedItemData>(lensGear.Key), Manufacturer = makerRef,
Roles = { new ProductRole { Role = "lens", Mean = .3f, StandardDeviation = 0 } }
});
var premiumProduct = cache.Upsert(new FactionProductData
{
Name = "Lensed Premium", Design = new CultRecordRef<CraftedItemData>(lensGear.Key), Manufacturer = makerRef,
Roles = { new ProductRole { Role = "lens", Mean = .9f, StandardDeviation = 0 } }
});
cache.FlushAsync().Wait();
var items = new ItemManager(1u, cache, new ProvenanceLedger(), Settings(), _ => { });
var cheapLot = items.CreateLot(cache.GetAll<FactionProductData>().Single(p => p.Name == "Lensed Economy"));
var premiumLot = items.CreateLot(cache.GetAll<FactionProductData>().Single(p => p.Name == "Lensed Premium"));
var cheapInstance = (EquippableItem) items.CreateInstance(cheapLot);
var premiumInstance = (EquippableItem) items.CreateInstance(premiumLot);
var stat = new PerformanceStat { Min = 0, Max = 1, Terms = { new StatTerm { Source = StatSource.Quality, Exponent = 1, Role = "lens" } } };
var cheapValue = items.Evaluate(stat, cheapInstance);
var premiumValue = items.Evaluate(stat, premiumInstance);
Assert.Equal(.3f, cheapValue, 3);
Assert.Equal(.9f, premiumValue, 3);
Assert.NotEqual(cheapValue, premiumValue);
}
}