Merge cut missile-stats (Soul verdict cut-missile-stats.s2)
This commit is contained in:
commit
a19e3ba647
11 changed files with 539 additions and 29 deletions
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
125
Assets/Scripts/ServerShared/Behaviors/MunitionParts.cs
Normal file
125
Assets/Scripts/ServerShared/Behaviors/MunitionParts.cs
Normal 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();
|
||||
}
|
||||
11
Assets/Scripts/ServerShared/Behaviors/MunitionParts.cs.meta
Normal file
11
Assets/Scripts/ServerShared/Behaviors/MunitionParts.cs.meta
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 6d4eac117ca042aaba71d1f6491b4617
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -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)
BIN
GameData/Aetheria.cc
(Stored with Git LFS)
Binary file not shown.
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
347
tests/Aetheria.Shared.Tests/MunitionPartsTests.cs
Normal file
347
tests/Aetheria.Shared.Tests/MunitionPartsTests.cs
Normal 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); }
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue