Merge cut missile-stats (Soul verdict cut-missile-stats.s2)

This commit is contained in:
Meta Vens 2026-10-12 20:54:30 +02:00
commit a19e3ba647
11 changed files with 539 additions and 29 deletions

View file

@ -44,7 +44,7 @@ public class GuidedProjectileManager : InstantWeaponEffectManager
p.SourceEntity = source.Entity;
if (target != null) p.Target = target.transform;
else p.TargetPosition = () => burst;
p.Frequency = launcher.DodgeFrequency;
p.Frequency = item.Evaluate(MunitionParts.Of<MunitionControlData>(item).EvasionFrequency);
var hp = item.Hardpoint;
var barrel = source.GetBarrel(hp);
p.StartPosition = (p.transform.position = barrel.position).ToCultMath();
@ -53,8 +53,8 @@ public class GuidedProjectileManager : InstantWeaponEffectManager
p.LiftCurve = launcher.LiftCurve.ToCurve();
p.ThrustCurve = launcher.ThrustCurve.ToCurve();
p.Velocity = barrel.forward * weapon.Velocity;
p.Thrust = item.Evaluate(launcher.Thrust);
p.TopSpeed = item.Evaluate(launcher.MissileVelocity);
p.Thrust = item.Evaluate(MunitionParts.Of<MunitionThrusterData>(item).Acceleration);
p.TopSpeed = item.Evaluate(MunitionParts.Of<MunitionThrusterData>(item).MaxSpeed);
Bind(p, source.Entity.Zone, shotId);
// A round with no target (a fused round fired at none) has no transform to hand a listener.
OnFireGuided.OnNext((source.Entity, target != null ? target.transform : null, p));
@ -65,7 +65,7 @@ public class GuidedProjectileManager : InstantWeaponEffectManager
p.ShotId = shotId;
p.Source = source.transform;
p.SourceEntity = source.Entity;
p.Frequency = guidance.DodgeFrequency;
p.Frequency = item.Evaluate(MunitionParts.Of<MunitionControlData>(item).EvasionFrequency);
var hp = item.Hardpoint;
var barrel = source.GetBarrel(hp);
p.StartPosition = (p.transform.position = barrel.position).ToCultMath();
@ -74,8 +74,8 @@ public class GuidedProjectileManager : InstantWeaponEffectManager
p.LiftCurve = guidance.LiftCurve.ToCurve();
p.ThrustCurve = guidance.ThrustCurve.ToCurve();
p.Velocity = barrel.forward * weapon.Velocity;
p.Thrust = item.Evaluate(guidance.Thrust);
p.TopSpeed = item.Evaluate(guidance.MissileVelocity);
p.Thrust = item.Evaluate(MunitionParts.Of<MunitionThrusterData>(item).Acceleration);
p.TopSpeed = item.Evaluate(MunitionParts.Of<MunitionThrusterData>(item).MaxSpeed);
p.TargetPosition = () => (source.Entity.Position + length(source.LookAtPoint.position.ToCultMath() - source.Entity.Position) * source.Entity.Aim).ToUnity();
Bind(p, source.Entity.Zone, shotId);
}

View file

@ -80,11 +80,13 @@ public static class CultRecordRefs
{
StatValidation.ValidateHeatResponse(data);
StatValidation.ValidateStatModifiers(data.Name, data.Behaviors);
StatValidation.ValidateMunitionParts(data.Name, data.Behaviors);
StatValidation.ValidateRoleUsage(data.Name, data.Roles, data.Behaviors);
}
if (document is ConsumableItemData consumable)
{
StatValidation.ValidateStatModifiers(consumable.Name, consumable.Behaviors);
StatValidation.ValidateMunitionParts(consumable.Name, consumable.Behaviors);
StatValidation.ValidateRoleUsage(consumable.Name, consumable.Roles, consumable.Behaviors);
}
}

View file

@ -184,6 +184,11 @@ public interface IPowerConsumer
Union(40, typeof(MineLayerData)),
Union(41, typeof(ThrottleLockData)),
Union(42, typeof(VapourDumpData)),
Union(43, typeof(MunitionHullData)),
Union(44, typeof(MunitionThrusterData)),
Union(45, typeof(MunitionSeekerData)),
Union(46, typeof(MunitionControlData)),
Union(47, typeof(MunitionFuelData)),
JsonObject(MemberSerialization.OptIn)]
public abstract class BehaviorData
{

View file

@ -18,14 +18,7 @@ public class LauncherData : LockWeaponData
[InspectableAnimationCurve, JsonProperty("liftCurve"), Key(28)]
public float4[] LiftCurve;
[Inspectable, JsonProperty("thrust"), Key(29)]
public PerformanceStat Thrust = new PerformanceStat();
[Inspectable, JsonProperty("frequency"), Key(30)]
public float DodgeFrequency;
[Inspectable, JsonProperty("missileSpeed"), Key(31), RuntimeInspectable]
public PerformanceStat MissileVelocity = new PerformanceStat();
// Keys 29-31 were Thrust, DodgeFrequency and MissileVelocity, retired for the munition parts (MunitionParts.cs); do not reuse.
public override Behavior CreateInstance(EquippedItem item)
{
@ -45,14 +38,7 @@ public class GuidedWeaponData : InstantWeaponData
[InspectableAnimationCurve, JsonProperty("liftCurve"), Key(23)]
public float4[] LiftCurve;
[Inspectable, JsonProperty("thrust"), Key(24)]
public PerformanceStat Thrust = new PerformanceStat();
[Inspectable, JsonProperty("frequency"), Key(25)]
public float DodgeFrequency;
[Inspectable, JsonProperty("missileSpeed"), Key(26), RuntimeInspectable]
public PerformanceStat MissileVelocity = new PerformanceStat();
// Keys 24-26 were Thrust, DodgeFrequency and MissileVelocity, retired for the munition parts (MunitionParts.cs); do not reuse.
public override Behavior CreateInstance(EquippedItem item)
{

View file

@ -0,0 +1,125 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
using System;
using System.Linq;
using MessagePack;
using Newtonsoft.Json;
// A missile type's stats, as five dedicated gear-part behaviours on the launcher's own design, one per gear
// category the entity cut will derive them from (ruling drones-and-munitions-are-entities: "dedicated gear so a
// missile thruster is not the same as a ship thruster"). Ruling missile-stats-now-gear-later, in the operator's
// words: "For now these stats will belong to the missile, later they will be derived from gear." Each part becomes
// a derivation from (or moves onto) that munition gear when munitions are entities.
//
// Each part is its own BehaviorData because every stat walker (role validation, StatModifier targeting, AetherDb
// roles, the trade panel) reads PerformanceStat fields one level deep. The parts are inert: nothing here executes,
// and no ship-wide reader (thrust allocator, sensing, signatures) reads them, because a missile thruster is not a
// ship thruster and a seeker is not a ship sensor. MunitionParts.Of is the one read path.
public abstract class MunitionPartData : BehaviorData
{
public override Behavior CreateInstance(EquippedItem item) => new MunitionPart(this, item);
public override Behavior CreateInstance(ConsumableItemEffect consumable) => new MunitionPart(this, consumable);
}
// What a part instantiates to: no state and no override, so Execute stays the default true.
public sealed class MunitionPart : Behavior
{
public MunitionPart(MunitionPartData data, EquippedItem item) : base(data, item) { }
public MunitionPart(MunitionPartData data, ConsumableItemEffect consumable) : base(data, consumable) { }
}
public static class MunitionParts
{
// The design's one part of that type. Catalog load and Upsert refuse a munition without exactly one of each.
public static T Of<T>(EquippedItem item) where T : MunitionPartData =>
item.Behaviors.Select(b => b.Data).OfType<T>().Single();
}
// The missile hull.
[Inspectable, MessagePackObject, JsonObject(MemberSerialization.OptIn)]
public sealed class MunitionHullData : MunitionPartData
{
[Inspectable, JsonProperty("durability"), Key(1), RuntimeInspectable]
public PerformanceStat Durability = new PerformanceStat();
// Metres.
[Inspectable, JsonProperty("radius"), Key(2)]
public PerformanceStat Radius = new PerformanceStat();
}
// The missile thruster.
[Inspectable, MessagePackObject, JsonObject(MemberSerialization.OptIn)]
public sealed class MunitionThrusterData : MunitionPartData
{
// The main motor, m/s^2.
[Inspectable, JsonProperty("acceleration"), Key(1), RuntimeInspectable]
public PerformanceStat Acceleration = new PerformanceStat();
// The sideways acceleration cap, m/s^2.
[Inspectable, JsonProperty("lateral"), Key(2)]
public PerformanceStat Lateral = new PerformanceStat();
// m/s.
[Inspectable, JsonProperty("maxSpeed"), Key(3), RuntimeInspectable]
public PerformanceStat MaxSpeed = new PerformanceStat();
// The strength of this motor's DrivePlume at full throttle, as ThrusterData.Visibility. A datum only: it is
// never registered as the launching ship's signature.
[Inspectable, JsonProperty("visibility"), Key(4)]
public PerformanceStat Visibility = new PerformanceStat();
}
// The seeker. Passive here: it reads a target and emits nothing.
[Inspectable, MessagePackObject, JsonObject(MemberSerialization.OptIn)]
public sealed class MunitionSeekerData : MunitionPartData
{
// Detection power, as SensorData.Sensitivity.
[Inspectable, JsonProperty("sensitivity"), Key(1)]
public PerformanceStat Sensitivity = new PerformanceStat();
// The full cone, in degrees (ruling sensor-stat-set's name and unit).
[Inspectable, JsonProperty("beamWidth"), Key(2)]
public PerformanceStat BeamWidth = new PerformanceStat();
// Metres.
[Inspectable, JsonProperty("range"), Key(3)]
public PerformanceStat Range = new PerformanceStat();
}
// Guidance and its cognition.
[Inspectable, MessagePackObject, JsonObject(MemberSerialization.OptIn)]
public sealed class MunitionControlData : MunitionPartData
{
// The proportional-navigation constant.
[Inspectable, JsonProperty("navigation"), Key(1)]
public PerformanceStat Navigation = new PerformanceStat();
// The share of Lateral spent jinking.
[Inspectable, JsonProperty("evasion"), Key(2)]
public PerformanceStat Evasion = new PerformanceStat();
// Hz.
[Inspectable, JsonProperty("evasionFrequency"), Key(3), RuntimeInspectable]
public PerformanceStat EvasionFrequency = new PerformanceStat();
// Seconds.
[Inspectable, JsonProperty("lifetime"), Key(4)]
public PerformanceStat Lifetime = new PerformanceStat();
// The missile's own skill, 0 to 1 (ruling missile-cognition: "a smarter missile can better spend its delta v").
// Four uses: guidance efficiency, when to jink against point defense, how much fuel it keeps for the terminal,
// and the price of the hit.
[Inspectable, JsonProperty("cognition"), Key(5)]
public PerformanceStat Cognition = new PerformanceStat();
}
// The tank.
[Inspectable, MessagePackObject, JsonObject(MemberSerialization.OptIn)]
public sealed class MunitionFuelData : MunitionPartData
{
// Total manoeuvre budget, m/s.
[Inspectable, JsonProperty("deltaV"), Key(1)]
public PerformanceStat DeltaV = new PerformanceStat();
}

View file

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 6d4eac117ca042aaba71d1f6491b4617
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -815,6 +815,23 @@ public static class StatValidation
}
}
// Ruling missile-stats-now-gear-later: a munition design (a LauncherData or GuidedWeaponData behaviour) carries exactly
// one of each of the five munition parts, and any other design carries none. Runs wherever ValidateStatModifiers runs,
// so a launcher missing a part is named at catalog load and Upsert rather than at its first launch.
public static void ValidateMunitionParts(string ownerName, IEnumerable<BehaviorData> behaviors)
{
var all = (behaviors ?? Enumerable.Empty<BehaviorData>()).Where(b => b != null).ToArray();
var isMunition = all.Any(b => b is LauncherData || b is GuidedWeaponData);
foreach (var part in new[] { typeof(MunitionHullData), typeof(MunitionThrusterData), typeof(MunitionSeekerData), typeof(MunitionControlData), typeof(MunitionFuelData) })
{
var count = all.Count(b => b.GetType() == part);
var expected = isMunition ? 1 : 0;
if (count != expected)
throw new InvalidOperationException(
$"{ownerName}: carries {count} {part.Name}, but a {(isMunition ? "munition" : "non-munition")} design carries {expected}");
}
}
// 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
// -- that is the intended forward-compat path for an old lot meeting a newly-authored role. This is the other

BIN
GameData/Aetheria.cc (Stored with Git LFS)

Binary file not shown.

View file

@ -68,7 +68,7 @@ public sealed class LockWarningTests : IDisposable
private static PerformanceStat Const(float v) => new PerformanceStat { Min = v, Max = v };
private static GearData Launcher(string name, float lockSpeed, float sensorImpact, float lockAngle, float directionImpact) => new GearData
private static GearData Launcher(string name, float lockSpeed, float sensorImpact, float lockAngle, float directionImpact) => WithMunitionParts(new GearData
{
Name = name, Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1,
MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000,
@ -84,7 +84,14 @@ public sealed class LockWarningTests : IDisposable
DirectionImpact = Const(directionImpact), Decay = Const(.1f)
}
}
};
});
// A launcher design carries the five munition parts (StatValidation.ValidateMunitionParts); these fixtures are about the lock.
private static GearData WithMunitionParts(GearData launcher)
{
launcher.Behaviors.AddRange(MunitionPartsTests.Parts(0));
return launcher;
}
private int _nextLot = 2;

View file

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

View file

@ -168,9 +168,18 @@ public static class Program
["MinRange"] = "guidance system",
["Cooldown"] = "guidance system", ["Spread"] = "guidance system", ["Visibility"] = "guidance system",
["Energy"] = "guidance system", ["Heat"] = "guidance system",
["Thrust"] = "thruster", ["MissileVelocity"] = "thruster", ["Velocity"] = "thruster",
["Velocity"] = "thruster",
// The munition thruster part's own stats. A part's fields are keyed by type and field (RoleKey), because Range
// and Visibility also name the seeker's and plume's flat stats, which this map must not range.
["MunitionThrusterData.Acceleration"] = "thruster", ["MunitionThrusterData.Lateral"] = "thruster",
["MunitionThrusterData.MaxSpeed"] = "thruster",
};
// How a role map names a behaviour's stat: a munition part's fields by type and field (several parts share a field name
// with the launcher's own stats), every other behaviour's by field.
private static string RoleKey(BehaviorData behavior, string field) =>
behavior is MunitionPartData ? $"{behavior.GetType().Name}.{field}" : field;
// Warhead and dispenser: a mine layer is a launcher without guidance or thrust, so its stats split between the
// charge it lays and the mechanism that lays it. ArmingDelay, FuseDelay and Lifetime are timings, not stats.
private static readonly Dictionary<string, string> MineLayerRoles = new Dictionary<string, string>
@ -308,7 +317,7 @@ public static class Program
if (stat.Min == stat.Max) continue; // flat: no observable role-vs-workmanship difference
var qualityTerm = stat.Terms.FirstOrDefault(t => t.Source == StatSource.Quality);
if (qualityTerm == null || !string.IsNullOrEmpty(qualityTerm.Role)) continue;
if (!map.TryGetValue(field.Name, out var role)) continue;
if (!map.TryGetValue(RoleKey(behavior, field.Name), out var role)) continue;
qualityTerm.Role = role;
rolesUsed.Add(role);
assigned.Add((behavior.GetType().Name, field.Name, role));
@ -1844,7 +1853,7 @@ public static class Program
{
var stat = field.GetValue(behavior) as PerformanceStat;
if (stat == null) continue;
if (!map.TryGetValue(field.Name, out var role)) continue;
if (!map.TryGetValue(RoleKey(behavior, field.Name), out var role)) continue;
stat.Terms ??= new List<StatTerm>();
var quality = stat.Terms.FirstOrDefault(t => t.Source == StatSource.Quality);
var value = stat.Min;
@ -2148,4 +2157,5 @@ public static class Program
Console.WriteLine($"\nAuthored 2 designs and {products.Length} products in Aetheria.cc");
return 0;
}
}