Merge origin/master into generic-design-names
Conflicts: AetherDb usage line (kept master's list plus generic-designs [apply]), Program.cs class tail (kept the launch-suppressor catalog and GenericDesigns both), GameData/Aetheria.cc taken from origin/master (f1642a12; the generic-designs apply rerun follows in its own commit). The old-name sweep of the newly landed code follows separately. This commit does not pass the generic-name tests: the catalog is master's, not yet renamed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
a8394f3f92
41 changed files with 2106 additions and 381 deletions
|
|
@ -4818,6 +4818,3 @@ MonoBehaviour:
|
|||
m_Name:
|
||||
m_EditorClassIdentifier:
|
||||
ParticleSystem: {fileID: 5087162364086924483}
|
||||
Radius: 25
|
||||
Traction: 25
|
||||
Distance: 75
|
||||
|
|
|
|||
|
|
@ -811,6 +811,9 @@ MonoBehaviour:
|
|||
Camera: {fileID: 1153356615}
|
||||
TestField: {fileID: 909026488}
|
||||
Properties: {fileID: 401779935}
|
||||
GrabExtendTime: 1.5
|
||||
GrabEnvelopTime: 0.75
|
||||
GrabPullTime: 1.5
|
||||
--- !u!4 &699508827
|
||||
Transform:
|
||||
m_ObjectHideFlags: 0
|
||||
|
|
@ -1116,9 +1119,6 @@ MonoBehaviour:
|
|||
TendrilBaseDamping: 2
|
||||
TendrilTipRadiusAnimationExponent: 2
|
||||
TendrilFadeExponent: 2
|
||||
GrabExtendTime: 1.5
|
||||
GrabEnvelopTime: 0.75
|
||||
GrabPullTime: 1.5
|
||||
RefractionAxis: 1
|
||||
--- !u!114 &909026489
|
||||
MonoBehaviour:
|
||||
|
|
|
|||
208
Assets/Scripts/Editor/CapabilityPresentationSmoke.cs
Normal file
208
Assets/Scripts/Editor/CapabilityPresentationSmoke.cs
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using GameCult.Caching;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using CultMath;
|
||||
using static CultMath.math;
|
||||
|
||||
// Batch proof of the capability presenters' wiring, without a scene: a zone ship is bound to an EntityInstance through the
|
||||
// game's own SetEntity, and impacts and grabs are published on the ship's Capabilities exactly as the simulation does. A hull absorb spawns
|
||||
// one hull effect under the instance and, for the player's ship at ScreenShake 1, raises one camera impulse; ScreenShake 0
|
||||
// or another ship raises none; a shield absorb spawns no hull effect. The shield ripple itself (ShieldManager.ShowHit
|
||||
// needs a shield material and a rig) is looked at in the editor, not here. Grabs: a Tractor Beam prefab under the instance
|
||||
// is bound by the same SetEntity and presents a grab from events alone (emission at Power and the aim at the held point
|
||||
// from Started to Completed or Cancelled, nothing otherwise, nothing for another entity's grab).
|
||||
//
|
||||
// Unity -batchmode -nographics -projectPath <project> -executeMethod CapabilityPresentationSmoke.Run -logFile <log>
|
||||
//
|
||||
// Do not pass -quit: Run exits the Editor itself, 0 on success and 1 on any failure.
|
||||
public static class CapabilityPresentationSmoke
|
||||
{
|
||||
private const BindingFlags Any = BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic;
|
||||
|
||||
public static void Run()
|
||||
{
|
||||
var work = Path.Combine(Path.GetTempPath(), $"capability-presentation-smoke-{Guid.NewGuid():N}");
|
||||
GameObject[] objects = null;
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(work);
|
||||
objects = Check(work);
|
||||
Clean(objects, work);
|
||||
EditorApplication.Exit(0);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
Debug.LogException(exception);
|
||||
Clean(objects, work);
|
||||
EditorApplication.Exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Edit mode runs no Awake or Start; the smoke runs the ones the game's scene would have.
|
||||
private static void Awaken(Component component) => component.GetType().GetMethod("Awake", Any)?.Invoke(component, null);
|
||||
|
||||
private static GameObject[] Check(string work)
|
||||
{
|
||||
var settings = AssetDatabase.LoadAssetAtPath<GameSettings>("Assets/Resources/Settings.asset")?.GameplaySettings
|
||||
?? throw new InvalidOperationException("No Settings.asset.");
|
||||
|
||||
var shape = new Shape(5, 4);
|
||||
foreach (var cell in shape.AllCoordinates) shape[cell] = true;
|
||||
var hullData = new HullData
|
||||
{
|
||||
Name = "Smoke Hull", HullType = HullType.Ship, Shape = shape, Durability = 1000, Mass = 1000, Armor = 0
|
||||
};
|
||||
var cache = AetheriaStores.Open(Path.Combine(work, "smoke.cc"), catalogWritable: true);
|
||||
cache.Upsert(new VerseGrammar { Revision = 1 });
|
||||
cache.Upsert(hullData);
|
||||
cache.FlushAsync().Wait();
|
||||
var ledger = new ProvenanceLedger();
|
||||
for (var i = 1; i <= 8; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = 1 };
|
||||
var items = new ItemManager(cache, ledger, settings, _ => { });
|
||||
var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Smoke", Owner = null }, null);
|
||||
var hullRef = items.ItemData.RefOf<ItemData>(items.ItemData.GetByName<HullData>("Smoke Hull"));
|
||||
var ship = new Ship(items, zone, new EquippableItem { Data = hullRef, Durability = 1000, Lot = 1 }, new EntitySettings());
|
||||
var other = new Ship(items, zone, new EquippableItem { Data = hullRef, Durability = 1000, Lot = 2 }, new EntitySettings());
|
||||
zone.Admit(ship, piloted: false);
|
||||
zone.Admit(other, piloted: false);
|
||||
|
||||
// The scene's side: the tuning object with a pooled effect, and the game manager that names the player's ship.
|
||||
var feedbackObject = new GameObject("Smoke Impact Feedback");
|
||||
var feedback = feedbackObject.AddComponent<ImpactFeedback>();
|
||||
Awaken(feedback);
|
||||
var effectObject = new GameObject("Smoke Hull Effect");
|
||||
feedback.HullEffect = effectObject.AddComponent<Prototype>();
|
||||
var managerObject = new GameObject("Smoke Game Manager");
|
||||
var manager = managerObject.AddComponent<ActionGameManager>();
|
||||
typeof(ActionGameManager).GetProperty("Instance", Any).SetValue(null, manager);
|
||||
manager.CurrentEntity = ship;
|
||||
|
||||
var beamPrefab = AssetDatabase.LoadAssetAtPath<GameObject>("Assets/Content/Prefabs/Tractor Beam.prefab")
|
||||
?? throw new InvalidOperationException("No Tractor Beam prefab.");
|
||||
// An entity's instance as the game builds it: the beam is a child before SetEntity on both ship paths, so the
|
||||
// binder finds it as the game does.
|
||||
(GameObject Object, EntityInstance Instance, TractorBeam Beam) Bound(string name, Entity entity)
|
||||
{
|
||||
var go = new GameObject(name);
|
||||
var bound = go.AddComponent<EntityInstance>();
|
||||
Awaken(bound);
|
||||
bound.HullColliders = new HullCollider[0];
|
||||
bound.RadiatorHardpoints = new RadiatorHardpoint[0];
|
||||
bound.ThrusterHardpoints = new ThrusterHardpoint[0];
|
||||
bound.WeaponHardpoints = new WeaponHardpoint[0];
|
||||
bound.ArticulationPoints = new ArticulationPoint[0];
|
||||
var tractor = ((GameObject)PrefabUtility.InstantiatePrefab(beamPrefab, go.transform)).GetComponent<TractorBeam>();
|
||||
tractor.Power = .8f;
|
||||
tractor.Direction = Vector3.forward;
|
||||
bound.SetEntity(null, entity);
|
||||
return (go, bound, tractor);
|
||||
}
|
||||
var (instanceObject, instance, beam) = Bound("Smoke Entity Instance", ship);
|
||||
var (otherObject, _, otherBeam) = Bound("Smoke Other Instance", other);
|
||||
|
||||
int Effects() => instance.GetComponentsInChildren<Prototype>(true).Count(p => p != null && !p.isOriginalPrototype);
|
||||
void Expect(string what, int effects, int kicks)
|
||||
{
|
||||
if (Effects() != effects || feedback.KicksRaised != kicks)
|
||||
throw new InvalidOperationException($"{what}: {Effects()} hull effects (want {effects}), {feedback.KicksRaised} impulses (want {kicks}).");
|
||||
}
|
||||
AbsorbEvent Hit(AbsorbLayer layer) => new AbsorbEvent(float3(1f, 0f, 2f), float3(0f, 0f, 1f), 100f, DamageType.Kinetic, layer);
|
||||
|
||||
ActionGameManager.PlayerSettings.GraphicsSettings.ScreenShake = 1f;
|
||||
ship.Capabilities.PublishAbsorb(Hit(AbsorbLayer.Hull));
|
||||
Expect("player's hull hit", 1, 1);
|
||||
var effect = instance.GetComponentsInChildren<Prototype>(true).Single(p => !p.isOriginalPrototype);
|
||||
var drawn = DrawAhead.Carried(ship, float3(1f, 0f, 2f), ActionGameManager.Clock.Lead);
|
||||
if (abs(effect.transform.position.x - drawn.x) > 1e-3f || abs(effect.transform.position.z - drawn.z) > 1e-3f)
|
||||
throw new InvalidOperationException("The hull effect is not at the drawn impact point.");
|
||||
|
||||
ActionGameManager.PlayerSettings.GraphicsSettings.ScreenShake = 0f;
|
||||
ship.Capabilities.PublishAbsorb(Hit(AbsorbLayer.Hull));
|
||||
Expect("hull hit at ScreenShake 0", 2, 1);
|
||||
|
||||
ActionGameManager.PlayerSettings.GraphicsSettings.ScreenShake = 1f;
|
||||
manager.CurrentEntity = other;
|
||||
ship.Capabilities.PublishAbsorb(Hit(AbsorbLayer.Hull));
|
||||
// An untracked ship is not drawn at (ActionGameManager.DrawsAt, since contact-hud): no hull effect, no impulse.
|
||||
Expect("hull hit on an untracked ship", 2, 1);
|
||||
|
||||
// Once the player's instruments track it, the same hit is drawn.
|
||||
other.VisibleEntities.Add(ship);
|
||||
ship.Capabilities.PublishAbsorb(Hit(AbsorbLayer.Hull));
|
||||
Expect("hull hit on a tracked ship", 3, 1);
|
||||
other.VisibleEntities.Remove(ship);
|
||||
|
||||
manager.CurrentEntity = ship;
|
||||
ship.Capabilities.PublishAbsorb(Hit(AbsorbLayer.Shield));
|
||||
Expect("player's shield hit", 3, 2);
|
||||
|
||||
// Grabs: the beam presents the simulation's events and nothing else.
|
||||
float Emission() => beam.ParticleSystem.emission.rateOverTimeMultiplier;
|
||||
void ExpectBeam(string what, float emission, bool towardHeld)
|
||||
{
|
||||
beam.Update();
|
||||
var forward = beam.transform.forward;
|
||||
var aimed = towardHeld ? forward.x > .999f : forward.z > .999f;
|
||||
if (abs(Emission() - emission) > 1e-4f || !aimed)
|
||||
throw new InvalidOperationException($"{what}: emission {Emission()} (want {emission}), forward {forward} (want {(towardHeld ? "+x" : "+z")}).");
|
||||
}
|
||||
GrabEvent Grab(GrabPhase phase, float3 at) => new GrabEvent(new FloatingBodyId(7), phase, at, 1f);
|
||||
|
||||
ExpectBeam("idle beam", 0f, false);
|
||||
ship.Capabilities.PublishGrab(Grab(GrabPhase.Started, float3(20f, 0f, 0f)));
|
||||
ExpectBeam("grab started", .8f, true);
|
||||
foreach (var phase in new[] { GrabPhase.Extend, GrabPhase.Envelop, GrabPhase.Pull })
|
||||
{
|
||||
ship.Capabilities.PublishGrab(Grab(phase, float3(20f, 0f, 0f)));
|
||||
ExpectBeam($"grab {phase}", .8f, true);
|
||||
}
|
||||
ship.Capabilities.PublishGrab(Grab(GrabPhase.Completed, float3(20f, 0f, 0f)));
|
||||
ExpectBeam("grab completed", 0f, false);
|
||||
|
||||
// A grab cancelled after its item vanished carries no position: the beam goes dark and never turns toward it.
|
||||
ship.Capabilities.PublishGrab(Grab(GrabPhase.Started, float3(20f, 0f, 0f)));
|
||||
ExpectBeam("second grab started", .8f, true);
|
||||
ship.Capabilities.PublishGrab(Grab(GrabPhase.Cancelled, float3(0f, 0f, 0f)));
|
||||
ExpectBeam("grab cancelled", 0f, false);
|
||||
|
||||
// Aim is on the ground plane: a beam above the held point does not pitch toward it.
|
||||
var beamHome = beam.transform.position;
|
||||
beam.transform.position = beamHome + new Vector3(0f, 10f, 0f);
|
||||
ship.Capabilities.PublishGrab(Grab(GrabPhase.Started, float3(20f, 7f, 0f)));
|
||||
ExpectBeam("grab from above", .8f, true);
|
||||
ship.Capabilities.PublishGrab(Grab(GrabPhase.Completed, float3(20f, 7f, 0f)));
|
||||
beam.transform.position = beamHome;
|
||||
|
||||
// A grab held at the beam's own position has no direction to take: the beam keeps the aim it had.
|
||||
beam.Direction = Vector3.right;
|
||||
var atBeam = float3(beam.transform.position.x, 0f, beam.transform.position.z);
|
||||
ship.Capabilities.PublishGrab(Grab(GrabPhase.Started, atBeam));
|
||||
ExpectBeam("grab at the beam", .8f, true);
|
||||
ship.Capabilities.PublishGrab(Grab(GrabPhase.Completed, atBeam));
|
||||
beam.Direction = Vector3.forward;
|
||||
ExpectBeam("after the grab at the beam", 0f, false);
|
||||
|
||||
// The beam belongs to the ship's entity: another entity's grab, with its own beam bound, moves only that beam.
|
||||
other.Capabilities.PublishGrab(Grab(GrabPhase.Started, float3(20f, 0f, 0f)));
|
||||
ExpectBeam("another entity's grab", 0f, false);
|
||||
otherBeam.Update();
|
||||
if (abs(otherBeam.ParticleSystem.emission.rateOverTimeMultiplier - .8f) > 1e-4f)
|
||||
throw new InvalidOperationException("The other entity's own beam did not present its grab.");
|
||||
|
||||
Debug.Log("CAPABILITY_PRESENTATION_SMOKE ok effects=3 impulses=2 grabs=ok");
|
||||
return new[] { feedbackObject, effectObject, managerObject, instanceObject, otherObject };
|
||||
}
|
||||
|
||||
private static void Clean(GameObject[] objects, string work)
|
||||
{
|
||||
if (objects != null)
|
||||
foreach (var go in objects)
|
||||
if (go != null) UnityEngine.Object.DestroyImmediate(go);
|
||||
try { if (Directory.Exists(work)) Directory.Delete(work, true); }
|
||||
catch (Exception error) { Debug.LogWarning($"Impact presenter smoke left {work}: {error.Message}"); }
|
||||
}
|
||||
}
|
||||
|
|
@ -180,6 +180,32 @@ public static class FloatingItemsDrawnSmoke
|
|||
At(drawn, released, "step");
|
||||
Require(drawn.transform.position != before, "the pickup did not move with the body.");
|
||||
|
||||
// A held item keeps its label lit, so a pull is read as it happens. The label is let go dark first (nothing views
|
||||
// it), then the zone holds the item through its own TryHold and the label must come up.
|
||||
var earlyPickup = Pickups().Single(p => p.Item == early.Item);
|
||||
earlyPickup.LabelPersistDuration = 1;
|
||||
earlyPickup.LabelFadeDuration = .2f;
|
||||
for (var i = 0; i < 1200 && earlyPickup.ScanLabel.color.a > .05f; i++) yield return null;
|
||||
Require(earlyPickup.ScanLabel.color.a <= .05f, "label: an unviewed, unheld label did not go dark.");
|
||||
Require(zone.TryHold(taker, early.Id), "label: the zone refused to hold the item.");
|
||||
for (var i = 0; i < 1200 && earlyPickup.ScanLabel.color.a < .95f; i++) yield return null;
|
||||
Require(earlyPickup.ScanLabel.color.a >= .95f, "label: a held item's label is not lit.");
|
||||
|
||||
// The test scene's stand-in sim and tendril ride the zone's own held-pose curve: its pose at each point of a grab
|
||||
// is where the zone puts a held item at the same progress.
|
||||
var (extend, envelop, pull) = (1.5f, .75f, 1.5f);
|
||||
var holderAt = new CultMath.float3(taker.Position.x, 0f, taker.Position.z);
|
||||
var claimedAt = new CultMath.float3(early.HeldFrom.x, 0f, early.HeldFrom.y);
|
||||
foreach (var elapsed in new[] { 0f, extend + envelop - .01f, extend + envelop + .3f, extend + envelop + .75f, extend + envelop + pull })
|
||||
{
|
||||
zone.SetHeldProgress(early.Id, Mathf.Clamp01((elapsed - extend - envelop) / pull)); // the pull starts after Extend and Envelop
|
||||
zone.Update(.01f);
|
||||
var want = FieldTester.PulledPosition(claimedAt, holderAt, elapsed, extend, envelop, pull);
|
||||
Require(Mathf.Abs(early.Body.Position.x - want.x) < 1e-3f && Mathf.Abs(early.Body.Position.y - want.z) < 1e-3f,
|
||||
$"stand-in pose at {elapsed}s is {want}, the zone holds the item at {early.Body.Position}.");
|
||||
}
|
||||
zone.Free(early.Id);
|
||||
|
||||
// Pickup destroys exactly the picked-up item's pickup.
|
||||
Require(zone.TryPickUp(taker, released.Id), "the station refused a one-cell stack.");
|
||||
yield return null;
|
||||
|
|
|
|||
|
|
@ -1,132 +0,0 @@
|
|||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using GameCult.Caching;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using static CultMath.math;
|
||||
|
||||
// Batch proof of ImpactPresenter's wiring, without a scene: a zone ship is bound to an EntityInstance through the game's
|
||||
// own SetEntity, and impacts are published on the ship's Capabilities exactly as FireControl does. A hull absorb spawns
|
||||
// one hull effect under the instance and, for the player's ship at ScreenShake 1, raises one camera impulse; ScreenShake 0
|
||||
// or another ship raises none; a shield absorb spawns no hull effect. The shield ripple itself (ShieldManager.ShowHit
|
||||
// needs a shield material and a rig) is looked at in the editor, not here.
|
||||
//
|
||||
// Unity -batchmode -nographics -projectPath <project> -executeMethod ImpactPresenterSmoke.Run -logFile <log>
|
||||
//
|
||||
// Do not pass -quit: Run exits the Editor itself, 0 on success and 1 on any failure.
|
||||
public static class ImpactPresenterSmoke
|
||||
{
|
||||
private const BindingFlags Any = BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic;
|
||||
|
||||
public static void Run()
|
||||
{
|
||||
var work = Path.Combine(Path.GetTempPath(), $"impact-presenter-smoke-{Guid.NewGuid():N}");
|
||||
GameObject[] objects = null;
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(work);
|
||||
objects = Check(work);
|
||||
Clean(objects, work);
|
||||
EditorApplication.Exit(0);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
Debug.LogException(exception);
|
||||
Clean(objects, work);
|
||||
EditorApplication.Exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Edit mode runs no Awake or Start; the smoke runs the ones the game's scene would have.
|
||||
private static void Awaken(Component component) => component.GetType().GetMethod("Awake", Any)?.Invoke(component, null);
|
||||
|
||||
private static GameObject[] Check(string work)
|
||||
{
|
||||
var settings = AssetDatabase.LoadAssetAtPath<GameSettings>("Assets/Resources/Settings.asset")?.GameplaySettings
|
||||
?? throw new InvalidOperationException("No Settings.asset.");
|
||||
|
||||
var shape = new Shape(5, 4);
|
||||
foreach (var cell in shape.AllCoordinates) shape[cell] = true;
|
||||
var hullData = new HullData
|
||||
{
|
||||
Name = "Smoke Hull", HullType = HullType.Ship, Shape = shape, Durability = 1000, Mass = 1000, Armor = 0
|
||||
};
|
||||
var cache = AetheriaStores.Open(Path.Combine(work, "smoke.cc"), catalogWritable: true);
|
||||
cache.Upsert(new VerseGrammar { Revision = 1 });
|
||||
cache.Upsert(hullData);
|
||||
cache.FlushAsync().Wait();
|
||||
var ledger = new ProvenanceLedger();
|
||||
for (var i = 1; i <= 8; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = 1 };
|
||||
var items = new ItemManager(cache, ledger, settings, _ => { });
|
||||
var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Smoke", Owner = null }, null);
|
||||
var hullRef = items.ItemData.RefOf<ItemData>(items.ItemData.GetByName<HullData>("Smoke Hull"));
|
||||
var ship = new Ship(items, zone, new EquippableItem { Data = hullRef, Durability = 1000, Lot = 1 }, new EntitySettings());
|
||||
var other = new Ship(items, zone, new EquippableItem { Data = hullRef, Durability = 1000, Lot = 2 }, new EntitySettings());
|
||||
zone.Admit(ship, piloted: false);
|
||||
zone.Admit(other, piloted: false);
|
||||
|
||||
// The scene's side: the tuning object with a pooled effect, and the game manager that names the player's ship.
|
||||
var feedbackObject = new GameObject("Smoke Impact Feedback");
|
||||
var feedback = feedbackObject.AddComponent<ImpactFeedback>();
|
||||
Awaken(feedback);
|
||||
var effectObject = new GameObject("Smoke Hull Effect");
|
||||
feedback.HullEffect = effectObject.AddComponent<Prototype>();
|
||||
var managerObject = new GameObject("Smoke Game Manager");
|
||||
var manager = managerObject.AddComponent<ActionGameManager>();
|
||||
typeof(ActionGameManager).GetProperty("Instance", Any).SetValue(null, manager);
|
||||
manager.CurrentEntity = ship;
|
||||
|
||||
var instanceObject = new GameObject("Smoke Entity Instance");
|
||||
var instance = instanceObject.AddComponent<EntityInstance>();
|
||||
Awaken(instance);
|
||||
instance.HullColliders = new HullCollider[0];
|
||||
instance.RadiatorHardpoints = new RadiatorHardpoint[0];
|
||||
instance.ThrusterHardpoints = new ThrusterHardpoint[0];
|
||||
instance.WeaponHardpoints = new WeaponHardpoint[0];
|
||||
instance.ArticulationPoints = new ArticulationPoint[0];
|
||||
instance.SetEntity(null, ship);
|
||||
|
||||
int Effects() => instance.GetComponentsInChildren<Prototype>(true).Count(p => p != null && !p.isOriginalPrototype);
|
||||
void Expect(string what, int effects, int kicks)
|
||||
{
|
||||
if (Effects() != effects || feedback.KicksRaised != kicks)
|
||||
throw new InvalidOperationException($"{what}: {Effects()} hull effects (want {effects}), {feedback.KicksRaised} impulses (want {kicks}).");
|
||||
}
|
||||
AbsorbEvent Hit(AbsorbLayer layer) => new AbsorbEvent(float3(1f, 0f, 2f), float3(0f, 0f, 1f), 100f, DamageType.Kinetic, layer);
|
||||
|
||||
ActionGameManager.PlayerSettings.GraphicsSettings.ScreenShake = 1f;
|
||||
ship.Capabilities.PublishAbsorb(Hit(AbsorbLayer.Hull));
|
||||
Expect("player's hull hit", 1, 1);
|
||||
var effect = instance.GetComponentsInChildren<Prototype>(true).Single(p => !p.isOriginalPrototype);
|
||||
var drawn = DrawAhead.Carried(ship, float3(1f, 0f, 2f), ActionGameManager.Clock.Lead);
|
||||
if (abs(effect.transform.position.x - drawn.x) > 1e-3f || abs(effect.transform.position.z - drawn.z) > 1e-3f)
|
||||
throw new InvalidOperationException("The hull effect is not at the drawn impact point.");
|
||||
|
||||
ActionGameManager.PlayerSettings.GraphicsSettings.ScreenShake = 0f;
|
||||
ship.Capabilities.PublishAbsorb(Hit(AbsorbLayer.Hull));
|
||||
Expect("hull hit at ScreenShake 0", 2, 1);
|
||||
|
||||
ActionGameManager.PlayerSettings.GraphicsSettings.ScreenShake = 1f;
|
||||
manager.CurrentEntity = other;
|
||||
ship.Capabilities.PublishAbsorb(Hit(AbsorbLayer.Hull));
|
||||
Expect("hull hit on another ship", 3, 1);
|
||||
|
||||
manager.CurrentEntity = ship;
|
||||
ship.Capabilities.PublishAbsorb(Hit(AbsorbLayer.Shield));
|
||||
Expect("player's shield hit", 3, 2);
|
||||
|
||||
Debug.Log("IMPACT_PRESENTER_SMOKE ok effects=3 impulses=2");
|
||||
return new[] { feedbackObject, effectObject, managerObject, instanceObject };
|
||||
}
|
||||
|
||||
private static void Clean(GameObject[] objects, string work)
|
||||
{
|
||||
if (objects != null)
|
||||
foreach (var go in objects)
|
||||
if (go != null) UnityEngine.Object.DestroyImmediate(go);
|
||||
try { if (Directory.Exists(work)) Directory.Delete(work, true); }
|
||||
catch (Exception error) { Debug.LogWarning($"Impact presenter smoke left {work}: {error.Message}"); }
|
||||
}
|
||||
}
|
||||
|
|
@ -8,27 +8,18 @@ using UnityEngine;
|
|||
// playground-cut.md fork L: "presentation is chosen per item, independently of capability... one
|
||||
// presentation... can visualize any subset of an item's capabilities"). This binder has no knowledge
|
||||
// of FieldDriver or any other concrete presentation, and no knowledge of what publishes events --
|
||||
// today the FieldShieldTest scene calls Bind() directly; a real capability owner will do the same
|
||||
// later without this binder changing.
|
||||
// EntityInstance binds every entity's events, and the FieldShieldTest scene binds its stand-in sim's the same way.
|
||||
public class CapabilityPresentationBinder : MonoBehaviour
|
||||
{
|
||||
private readonly List<IDisposable> _subscriptions = new List<IDisposable>();
|
||||
private Func<FloatingBodyId, Transform> _grabTargetResolver;
|
||||
private Func<FloatingBodyId, Vector3> _grabInitialVelocityResolver;
|
||||
|
||||
// Subscribes every presenter interface implemented by components on this object or its children to
|
||||
// the matching stream of `events`. `grabTargetResolver`/`grabInitialVelocityResolver` resolve a
|
||||
// GrabEvent's target id to the live Transform/velocity a grab presenter needs; the owner
|
||||
// of `events` supplies them (the test scene today; the real pickup capability's caller later).
|
||||
public void Bind(CapabilityEvents events, Func<FloatingBodyId, Transform> grabTargetResolver = null,
|
||||
Func<FloatingBodyId, Vector3> grabInitialVelocityResolver = null)
|
||||
// the matching stream of `events`.
|
||||
public void Bind(CapabilityEvents events)
|
||||
{
|
||||
Unbind();
|
||||
if (events == null) return;
|
||||
|
||||
_grabTargetResolver = grabTargetResolver;
|
||||
_grabInitialVelocityResolver = grabInitialVelocityResolver;
|
||||
|
||||
foreach (var presenter in GetComponentsInChildren<IAbsorbPresenter>(true))
|
||||
{
|
||||
var p = presenter;
|
||||
|
|
@ -38,12 +29,7 @@ public class CapabilityPresentationBinder : MonoBehaviour
|
|||
foreach (var presenter in GetComponentsInChildren<IGrabPresenter>(true))
|
||||
{
|
||||
var p = presenter;
|
||||
_subscriptions.Add(events.Grab.Subscribe(e =>
|
||||
{
|
||||
var target = _grabTargetResolver?.Invoke(e.Target);
|
||||
var velocity = _grabInitialVelocityResolver?.Invoke(e.Target) ?? Vector3.zero;
|
||||
p.Grab(e, target, velocity);
|
||||
}));
|
||||
_subscriptions.Add(events.Grab.Subscribe(e => p.Grab(e)));
|
||||
}
|
||||
|
||||
foreach (var presenter in GetComponentsInChildren<IMeleePresenter>(true))
|
||||
|
|
@ -63,8 +49,6 @@ public class CapabilityPresentationBinder : MonoBehaviour
|
|||
{
|
||||
foreach (var s in _subscriptions) s.Dispose();
|
||||
_subscriptions.Clear();
|
||||
_grabTargetResolver = null;
|
||||
_grabInitialVelocityResolver = null;
|
||||
}
|
||||
|
||||
private void OnDisable() => Unbind();
|
||||
|
|
|
|||
|
|
@ -10,13 +10,10 @@ public interface IAbsorbPresenter
|
|||
void Absorb(AbsorbEvent e);
|
||||
}
|
||||
|
||||
// Grab needs a live Transform to animate, which the engine-free GrabEvent cannot carry (its
|
||||
// Target is a plain identity, not a Unity reference). The binder resolves the id through a
|
||||
// delegate supplied by the event source's owner and hands the presenter the result, along with the
|
||||
// initial velocity the target was already moving with (also owner-supplied, for the same reason).
|
||||
// Presenters read the event and the simulated pose, never a Transform the sim does not own.
|
||||
public interface IGrabPresenter
|
||||
{
|
||||
void Grab(GrabEvent e, Transform target, Vector3 initialVelocity);
|
||||
void Grab(GrabEvent e);
|
||||
}
|
||||
|
||||
public interface IMeleePresenter
|
||||
|
|
|
|||
|
|
@ -63,11 +63,7 @@ public class FieldDriver : MonoBehaviour, IAbsorbPresenter, IGrabPresenter, IMel
|
|||
// public float GrabScaleAnimationExponent = .5f;
|
||||
|
||||
[Inspectable]
|
||||
public float GrabExtendTime;
|
||||
[Inspectable]
|
||||
public float GrabEnvelopTime;
|
||||
[Inspectable]
|
||||
public float GrabPullTime;
|
||||
public float TendrilTipScale = 1;
|
||||
|
||||
[Inspectable]
|
||||
public RectTransform.Axis RefractionAxis = RectTransform.Axis.Horizontal;
|
||||
|
|
@ -81,29 +77,17 @@ public class FieldDriver : MonoBehaviour, IAbsorbPresenter, IGrabPresenter, IMel
|
|||
private float _meleeTime;
|
||||
private bool _meleeActive;
|
||||
private MeshCollider _collider;
|
||||
private Transform _grabObject;
|
||||
private Vector3 _grabObjectStartPos;
|
||||
private Vector3 _grabObjectStartTendrilBasePos;
|
||||
private Vector3 _grabObjectEndPos;
|
||||
private Vector3 _grabObjectVelocity;
|
||||
private float _grabObjectScale;
|
||||
private bool _grabbing;
|
||||
private GrabPhase _grabPhase;
|
||||
private float _grabTime;
|
||||
private TendrilPhase _grabPhase;
|
||||
private float _phaseDuration;
|
||||
private Vector3 _grabStart;
|
||||
private Vector3 _grabStartTendrilBase;
|
||||
private Vector3 _tendrilBasePos;
|
||||
private Vector3 _tendrilBendTarget;
|
||||
private Vector3 _tendrilTargetPos;
|
||||
private float _fadePoint;
|
||||
|
||||
// Named distinctly from the global GrabPhase (CapabilityEvents.cs): this is FieldDriver's own
|
||||
// internal tendril-animation timing, still owned here until a real pickup capability exists to
|
||||
// publish per-phase GrabEvent transitions (see Grab(GrabEvent, Transform, Vector3) below).
|
||||
private enum TendrilPhase
|
||||
{
|
||||
Extend,
|
||||
Envelop,
|
||||
Pull
|
||||
}
|
||||
|
||||
private struct FieldHit
|
||||
{
|
||||
public float3 Position;
|
||||
|
|
@ -181,32 +165,41 @@ public class FieldDriver : MonoBehaviour, IAbsorbPresenter, IGrabPresenter, IMel
|
|||
_meleeTime = 0;
|
||||
}
|
||||
|
||||
public bool CanGrab => _grabObject == null;
|
||||
|
||||
public void GrabObject(Transform t, Vector3 v)
|
||||
{
|
||||
_grabPhase = TendrilPhase.Extend;
|
||||
_grabObject = t;
|
||||
_grabObjectStartPos = t.position;
|
||||
_grabObjectVelocity = v;
|
||||
_grabObjectScale = t.localScale.x;
|
||||
_tendrilBasePos = _grabObjectStartTendrilBasePos = _collider.ClosestPoint(_grabObjectStartPos);
|
||||
_fadePoint = .999f;
|
||||
}
|
||||
|
||||
// IAbsorbPresenter: delegates straight to the existing hit-buffer mechanism. e.DamageType is not
|
||||
// read -- the field shield has no per-damage-type visual today; it rides along in the event for
|
||||
// presentations that do vary by type (the mask itself is absorb-capability data, out of scope here).
|
||||
public void Absorb(AbsorbEvent e) => AddHit(e.Position, e.Direction, e.Magnitude);
|
||||
|
||||
// IGrabPresenter: only the Started phase does anything today. FieldDriver still times the
|
||||
// extend/envelop/pull animation itself (TendrilPhase above, driven by GrabExtendTime/
|
||||
// GrabEnvelopTime/GrabPullTime) because no capability yet publishes those phase transitions;
|
||||
// once one does, this is where FieldDriver would react to Envelop/Pull/Completed/Cancelled too.
|
||||
public void Grab(GrabEvent e, Transform target, Vector3 initialVelocity)
|
||||
// IGrabPresenter: the simulation owns the grab; this draws the tendril from the events alone. Started records
|
||||
// where the item is held and where the tendril leaves the field; Extend, Envelop and Pull set the phase and
|
||||
// restart the real-time phase clock over the event's duration (it clamps at 1 until the next event, so a
|
||||
// slowed simulation stretches the wait, not the animation); Completed and Cancelled end it.
|
||||
public void Grab(GrabEvent e)
|
||||
{
|
||||
if (e.Phase == GrabPhase.Started && target != null)
|
||||
GrabObject(target, initialVelocity);
|
||||
switch (e.Phase)
|
||||
{
|
||||
case GrabPhase.Started:
|
||||
_grabbing = true;
|
||||
_grabPhase = GrabPhase.Started;
|
||||
_grabStart = e.TargetPosition.ToUnity();
|
||||
_tendrilBasePos = _grabStartTendrilBase = _collider.ClosestPoint(_grabStart);
|
||||
_tendrilTargetPos = _grabStart;
|
||||
_fadePoint = .999f;
|
||||
break;
|
||||
case GrabPhase.Extend:
|
||||
case GrabPhase.Envelop:
|
||||
case GrabPhase.Pull:
|
||||
if (!_grabbing) break;
|
||||
_grabPhase = e.Phase;
|
||||
_grabTime = 0;
|
||||
_phaseDuration = e.PhaseDuration;
|
||||
break;
|
||||
case GrabPhase.Completed:
|
||||
case GrabPhase.Cancelled:
|
||||
_grabbing = false;
|
||||
_field.SetFloat("_TendrilInfluence", 0);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// IMeleePresenter: the field shield's swing shape comes from its own authored curves
|
||||
|
|
@ -277,74 +270,47 @@ public class FieldDriver : MonoBehaviour, IAbsorbPresenter, IGrabPresenter, IMel
|
|||
}
|
||||
|
||||
|
||||
if(_grabObject != null)
|
||||
if (_grabbing && _grabPhase != GrabPhase.Started)
|
||||
{
|
||||
_grabTime += Time.deltaTime / _grabPhase switch
|
||||
_grabTime = min(1, _grabTime + Time.deltaTime / max(_phaseDuration, 1e-4f));
|
||||
_tendrilBasePos = damp(_tendrilBasePos.ToCultMath(), _tendrilTargetPos.ToCultMath(), TendrilBaseDamping, Time.deltaTime).ToUnity();
|
||||
switch (_grabPhase)
|
||||
{
|
||||
TendrilPhase.Extend => GrabExtendTime,
|
||||
TendrilPhase.Envelop => GrabEnvelopTime,
|
||||
TendrilPhase.Pull => GrabPullTime,
|
||||
_ => throw new ArgumentOutOfRangeException()
|
||||
};
|
||||
if (_grabTime > 1)
|
||||
{
|
||||
_grabTime = 0;
|
||||
if (_grabPhase == TendrilPhase.Envelop)
|
||||
{
|
||||
_grabObjectEndPos = _grabObject.position;
|
||||
}
|
||||
if (_grabPhase == TendrilPhase.Pull)
|
||||
{
|
||||
Destroy(_grabObject.gameObject);
|
||||
_grabObject = null;
|
||||
_field.SetFloat("_TendrilInfluence", 0);
|
||||
}
|
||||
else _grabPhase++;
|
||||
}
|
||||
|
||||
if (_grabObject != null)
|
||||
{
|
||||
_tendrilBasePos = damp(_tendrilBasePos.ToCultMath(), _grabObject.position.ToCultMath(), TendrilBaseDamping, Time.deltaTime).ToUnity();
|
||||
switch (_grabPhase)
|
||||
{
|
||||
case TendrilPhase.Extend:
|
||||
_tendrilBendTarget = _grabObjectStartPos;
|
||||
_grabObject.position += _grabObjectVelocity * Time.deltaTime;
|
||||
_tendrilTargetPos = lerp(
|
||||
lerp(_grabObjectStartTendrilBasePos.ToCultMath(), _grabObjectStartPos.ToCultMath(), _grabTime),
|
||||
lerp(_grabObjectStartPos.ToCultMath(), _grabObject.position.ToCultMath(), _grabTime),
|
||||
pow(_grabTime, TendrilExtensionExponent)).ToUnity();
|
||||
_field.SetFloat("_TendrilInfluence", pow(_grabTime, TendrilExtendBaseAnimationExponent));
|
||||
_field.SetFloat("_TendrilSize", lerp(TendrilBaseRadius/2,TendrilBaseRadius,pow(_grabTime, TendrilExtendBaseAnimationExponent)));
|
||||
_field.SetFloat("_TendrilRadius", _grabObjectScale * pow(_grabTime, TendrilTipRadiusAnimationExponent) * TendrilTipRadius);
|
||||
break;
|
||||
case TendrilPhase.Envelop:
|
||||
_tendrilBendTarget = _grabObjectStartPos;
|
||||
_tendrilTargetPos = _grabObject.position += _grabObjectVelocity * Time.deltaTime * (1-_grabTime);
|
||||
_field.SetFloat("_TendrilInfluence", 1);
|
||||
_field.SetFloat("_TendrilSize", TendrilBaseRadius);
|
||||
_field.SetFloat("_TendrilRadius", _grabObjectScale * TendrilTipRadius);
|
||||
break;
|
||||
case TendrilPhase.Pull:
|
||||
_tendrilTargetPos = _grabObject.position = lerp(_grabObjectEndPos.ToCultMath(), transform.position.ToCultMath(), _grabTime*_grabTime).ToUnity();
|
||||
_tendrilBendTarget = lerp(_grabObjectStartPos.ToCultMath(), _grabObjectEndPos.ToCultMath(), _grabTime*_grabTime).ToUnity();
|
||||
if (_fadePoint > .99 && transform.InverseTransformPoint(_grabObject.position).sqrMagnitude < 1)
|
||||
_fadePoint = _grabTime;
|
||||
_field.SetFloat("_TendrilInfluence", pow(smoothstep(1, _fadePoint, _grabTime), TendrilFadeExponent));
|
||||
_field.SetFloat("_TendrilSize", TendrilBaseRadius);
|
||||
_field.SetFloat("_TendrilRadius", _grabObjectScale * TendrilTipRadius);
|
||||
break;
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException();
|
||||
}
|
||||
var tendrilRadii = EnvelopeRadiiOrFallback();
|
||||
_field.SetVector("_TendrilBase", Vector3.Scale(transform.InverseTransformPoint(_tendrilBasePos).normalized, tendrilRadii));
|
||||
_field.SetVector("_TendrilBend", Vector3.Scale(transform.InverseTransformPoint(_tendrilBendTarget), tendrilRadii));
|
||||
_field.SetVector("_TendrilTarget", Vector3.Scale(transform.InverseTransformPoint(_tendrilTargetPos), tendrilRadii));
|
||||
case GrabPhase.Extend:
|
||||
_tendrilBendTarget = _grabStart;
|
||||
_tendrilTargetPos = lerp(
|
||||
lerp(_grabStartTendrilBase.ToCultMath(), _grabStart.ToCultMath(), _grabTime),
|
||||
_grabStart.ToCultMath(),
|
||||
pow(_grabTime, TendrilExtensionExponent)).ToUnity();
|
||||
_field.SetFloat("_TendrilInfluence", pow(_grabTime, TendrilExtendBaseAnimationExponent));
|
||||
_field.SetFloat("_TendrilSize", lerp(TendrilBaseRadius/2,TendrilBaseRadius,pow(_grabTime, TendrilExtendBaseAnimationExponent)));
|
||||
_field.SetFloat("_TendrilRadius", TendrilTipScale * pow(_grabTime, TendrilTipRadiusAnimationExponent) * TendrilTipRadius);
|
||||
break;
|
||||
case GrabPhase.Envelop:
|
||||
_tendrilBendTarget = _grabStart;
|
||||
_tendrilTargetPos = _grabStart;
|
||||
_field.SetFloat("_TendrilInfluence", 1);
|
||||
_field.SetFloat("_TendrilSize", TendrilBaseRadius);
|
||||
_field.SetFloat("_TendrilRadius", TendrilTipScale * TendrilTipRadius);
|
||||
break;
|
||||
case GrabPhase.Pull:
|
||||
// The curve of Zone's held pose (Zone.HeldPull): the tip rides the item the simulation pulls in.
|
||||
_tendrilTargetPos = lerp(_grabStart.ToCultMath(), transform.position.ToCultMath(), Zone.HeldPull(_grabTime)).ToUnity();
|
||||
_tendrilBendTarget = _grabStart;
|
||||
if (_fadePoint > .99 && transform.InverseTransformPoint(_tendrilTargetPos).sqrMagnitude < 1)
|
||||
_fadePoint = _grabTime;
|
||||
_field.SetFloat("_TendrilInfluence", pow(smoothstep(1, _fadePoint, _grabTime), TendrilFadeExponent));
|
||||
_field.SetFloat("_TendrilSize", TendrilBaseRadius);
|
||||
_field.SetFloat("_TendrilRadius", TendrilTipScale * TendrilTipRadius);
|
||||
break;
|
||||
}
|
||||
var tendrilRadii = EnvelopeRadiiOrFallback();
|
||||
_field.SetVector("_TendrilBase", Vector3.Scale(transform.InverseTransformPoint(_tendrilBasePos).normalized, tendrilRadii));
|
||||
_field.SetVector("_TendrilBend", Vector3.Scale(transform.InverseTransformPoint(_tendrilBendTarget), tendrilRadii));
|
||||
_field.SetVector("_TendrilTarget", Vector3.Scale(transform.InverseTransformPoint(_tendrilTargetPos), tendrilRadii));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private float almostIdentity( float x )
|
||||
{
|
||||
return x*x*(2.0f-x);
|
||||
|
|
@ -358,7 +324,7 @@ public class FieldDriver : MonoBehaviour, IAbsorbPresenter, IGrabPresenter, IMel
|
|||
private const int GIZMO_STEPS = 16;
|
||||
private void OnDrawGizmosSelected()
|
||||
{
|
||||
if (_grabObject != null)
|
||||
if (_grabbing)
|
||||
{
|
||||
Vector3 previous = _tendrilBasePos;
|
||||
for (int i = 1; i <= GIZMO_STEPS; i++)
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ public class ItemPickup : MonoBehaviour
|
|||
var viewAngle = acos(dot(toThis, ViewDirection)) * Mathf.Rad2Deg;
|
||||
if (length(diff) < LabelDisplayMaxDistance && viewAngle < LabelDisplayAngle)
|
||||
_displayTime = Time.time;
|
||||
if (_floating?.Holder != null) _displayTime = Time.time;
|
||||
var targetAlpha = Time.time - _displayTime < LabelPersistDuration ? 1 : 0;
|
||||
var c = ScanLabel.color;
|
||||
c.a = c.a + sign(targetAlpha - c.a) * (Time.deltaTime / LabelFadeDuration);
|
||||
|
|
|
|||
|
|
@ -1,22 +1,51 @@
|
|||
using System;
|
||||
using UnityEngine;
|
||||
using CultMath.UnityBridge;
|
||||
|
||||
public class TractorBeam : MonoBehaviour
|
||||
// Presents the ship's grab: while the simulation holds an item, the beam looks along the ground-plane vector to the
|
||||
// point the item was held from and emits at Power. Zone pulls the held item along the segment from the ship to that
|
||||
// point (docs/aetheria-release-map.md L11), which is why GrabEvent.TargetPosition on Started is the exact aim and no
|
||||
// pose lookup is needed. Idle, the beam turns with Direction and emits nothing. The simulation decides every grab fact.
|
||||
public class TractorBeam : MonoBehaviour, IGrabPresenter
|
||||
{
|
||||
public ParticleSystem ParticleSystem;
|
||||
public float Radius;
|
||||
public float Traction;
|
||||
public float Distance;
|
||||
|
||||
|
||||
public float Power { get; set; }
|
||||
public Vector3 Direction { get; set; }
|
||||
|
||||
private bool _grabbing;
|
||||
private Vector3 _heldPoint;
|
||||
private Vector3 _heldDirection;
|
||||
|
||||
// Cancelled can carry no position (a vanished item), so only Started is read for one.
|
||||
public void Grab(GrabEvent e)
|
||||
{
|
||||
switch (e.Phase)
|
||||
{
|
||||
case GrabPhase.Started:
|
||||
_grabbing = true;
|
||||
_heldPoint = e.TargetPosition.ToUnity();
|
||||
_heldDirection = Direction;
|
||||
break;
|
||||
case GrabPhase.Completed:
|
||||
case GrabPhase.Cancelled:
|
||||
_grabbing = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
public void Update()
|
||||
{
|
||||
transform.rotation = Quaternion.LookRotation(Direction);
|
||||
// The beam draws nothing until it presents a sim grab (loot-3): no particles, and no pull; the simulation
|
||||
// decides every floating item's motion.
|
||||
var look = Direction;
|
||||
if (_grabbing)
|
||||
{
|
||||
var toHeld = _heldPoint - transform.position;
|
||||
toHeld.y = 0;
|
||||
if (toHeld.sqrMagnitude > 1e-6f) _heldDirection = toHeld;
|
||||
look = _heldDirection;
|
||||
}
|
||||
transform.rotation = Quaternion.LookRotation(look);
|
||||
var emission = ParticleSystem.emission;
|
||||
emission.rateOverTimeMultiplier = 0;
|
||||
emission.rateOverTimeMultiplier = _grabbing ? Power : 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,18 +36,20 @@ public class CombatState : BaseState
|
|||
|
||||
// Cut 2 (docs/fire-control-cut.md): aim at whichever revealed weapon on the target hits hardest at
|
||||
// this range, or clear the aim point when nothing qualifies -- the same predicate
|
||||
// (FireControl.IsRevealed) and the same writer (TrySelectTargetItem) the player path uses.
|
||||
// (FireControl.IsRevealed) and the same writer (TrySelectTargetItem) the player path uses. Which weapons it
|
||||
// believes the target has, and how hard each hits, come from its book's claims, not the target's live gear
|
||||
// (aetheria-ew, classify-from-belief; ew-claim-weapon-measure-ruled: the claimed design's rated figures); the
|
||||
// reveal's Item is the aim and stays the truth.
|
||||
EquippedItem bestTargetItem = null;
|
||||
var bestTargetDps = float.MinValue;
|
||||
foreach (var targetItem in target.Equipment)
|
||||
foreach (var reveal in _agent.Ship.RevealedWeapons(target))
|
||||
{
|
||||
if (!(targetItem.Behaviors.FirstOrDefault(b => b is Weapon) is Weapon targetWeapon)) continue;
|
||||
if (!FireControl.IsRevealed(_agent.Ship, targetItem)) continue;
|
||||
var dps = targetWeapon.RangeDamagePerSecond(targetDistance);
|
||||
if (!FireControl.IsRevealed(_agent.Ship, reveal.Item)) continue;
|
||||
var dps = reveal.Claimed.Behaviors.OfType<WeaponData>().First().RatedRangeDamagePerSecond(targetDistance);
|
||||
if (dps > bestTargetDps)
|
||||
{
|
||||
bestTargetDps = dps;
|
||||
bestTargetItem = targetItem;
|
||||
bestTargetItem = reveal.Item;
|
||||
}
|
||||
}
|
||||
_agent.Ship.TrySelectTargetItem(bestTargetItem);
|
||||
|
|
|
|||
|
|
@ -54,6 +54,15 @@ public class ChargedWeaponData : InstantWeaponData
|
|||
{
|
||||
return new ChargedWeapon(this, item);
|
||||
}
|
||||
|
||||
// The one formula of a charged weapon's damage per second at a range (see InstantWeaponData.RangeDamagePerSecond).
|
||||
internal static float RangeDamagePerSecond(BezierCurve damageCurve, float damage, float chargeFiringDamageMultiplier,
|
||||
float minRange, float range, float cooldown, float chargeTime, float atRange) =>
|
||||
damage * chargeFiringDamageMultiplier * damageCurve.Evaluate(saturate(unlerp(minRange, range, atRange))) / (cooldown + chargeTime);
|
||||
|
||||
public override float RatedRangeDamagePerSecond(float range) =>
|
||||
RangeDamagePerSecond(DamageCurve, PerRound(Damage.Max, Count.Max), ChargeFiringDamageMultiplier, MinRange.Max, Range.Max,
|
||||
Cooldown.Max, ChargeTime.Max, range);
|
||||
}
|
||||
|
||||
public class ChargedWeapon : InstantWeapon
|
||||
|
|
@ -75,13 +84,14 @@ public class ChargedWeapon : InstantWeapon
|
|||
// authored charge-up speed IS its sustained rate of fire, not a second, disconnected number.
|
||||
protected override float RateOverride => Evaluate(_data.ChargeEnergy);
|
||||
|
||||
public override float DamagePerSecond => Damage * _data.ChargeFiringDamageMultiplier / (Cooldown + ChargeTime);
|
||||
// The charge multiplier lives in the formula alone: Damage is lerped by charge for the shot itself (UpdateStats), so the
|
||||
// live reads take the uncharged damage, and a nominal weapon reads its design's rated figure at every charge.
|
||||
private float _unchargedDamage;
|
||||
public override float DamagePerSecond => _unchargedDamage * _data.ChargeFiringDamageMultiplier / (Cooldown + ChargeTime);
|
||||
public override float RangeDamagePerSecond(float range)
|
||||
{
|
||||
return Damage *
|
||||
_data.ChargeFiringDamageMultiplier *
|
||||
_data.DamageCurve.Evaluate(saturate(unlerp(MinRange, Range, range))) /
|
||||
(Cooldown + ChargeTime);
|
||||
return ChargedWeaponData.RangeDamagePerSecond(_data.DamageCurve, _unchargedDamage, _data.ChargeFiringDamageMultiplier,
|
||||
MinRange, Range, Cooldown, ChargeTime, range);
|
||||
}
|
||||
|
||||
public event Action OnStartCharging;
|
||||
|
|
@ -121,6 +131,7 @@ public class ChargedWeapon : InstantWeapon
|
|||
ChargeTime = Evaluate(_data.ChargeTime);
|
||||
ChargeEnergy = Evaluate(_data.ChargeEnergy);
|
||||
ChargeHeat = Evaluate(_data.ChargeHeat);
|
||||
_unchargedDamage = Damage;
|
||||
Damage *= lerp(1, _data.ChargeFiringDamageMultiplier, saturate(_charge));
|
||||
Heat *= lerp(1, _data.ChargeFiringHeatMultiplier, saturate(_charge));
|
||||
Spread *= lerp(1, _data.ChargeFiringSpreadMultiplier, saturate(_charge));
|
||||
|
|
|
|||
|
|
@ -23,6 +23,13 @@ public class ConstantWeaponData : WeaponData
|
|||
{
|
||||
return new ConstantWeapon(this, item);
|
||||
}
|
||||
|
||||
// The one formula of a beam's damage per second at a range (see InstantWeaponData.RangeDamagePerSecond).
|
||||
internal static float RangeDamagePerSecond(BezierCurve damageCurve, float damage, float minRange, float range, float atRange) =>
|
||||
damage * damageCurve.Evaluate(saturate(unlerp(minRange, range, atRange)));
|
||||
|
||||
public override float RatedRangeDamagePerSecond(float range) =>
|
||||
RangeDamagePerSecond(DamageCurve, Damage.Max, MinRange.Max, Range.Max, range);
|
||||
}
|
||||
|
||||
public class ConstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerConsumer
|
||||
|
|
@ -49,7 +56,7 @@ public class ConstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerC
|
|||
public override float DamagePerSecond => Damage;
|
||||
public override float RangeDamagePerSecond(float range)
|
||||
{
|
||||
return Damage * _data.DamageCurve.Evaluate(saturate(unlerp(MinRange, Range, range)));
|
||||
return ConstantWeaponData.RangeDamagePerSecond(_data.DamageCurve, Damage, MinRange, Range, range);
|
||||
}
|
||||
|
||||
public event Action OnReloadBegin;
|
||||
|
|
|
|||
|
|
@ -30,6 +30,17 @@ public class InstantWeaponData : WeaponData
|
|||
{
|
||||
return new InstantWeapon(this, item);
|
||||
}
|
||||
|
||||
// The one formula of an instant weapon's damage per second at a range; the live weapon calls it with its evaluated
|
||||
// stats, the design with its rated ones. A burst's round carries its share of the totals: PerRound, the one divisor
|
||||
// UpdateStats and the rated read both use.
|
||||
internal static float PerRound(float total, float burstCount) => total / (int) burstCount;
|
||||
|
||||
internal static float RangeDamagePerSecond(BezierCurve damageCurve, float damage, float minRange, float range, float cooldown, float atRange) =>
|
||||
damage * damageCurve.Evaluate(saturate(unlerp(minRange, range, atRange))) / cooldown;
|
||||
|
||||
public override float RatedRangeDamagePerSecond(float range) =>
|
||||
RangeDamagePerSecond(DamageCurve, PerRound(Damage.Max, Count.Max), MinRange.Max, Range.Max, Cooldown.Max, range);
|
||||
}
|
||||
|
||||
public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerConsumer
|
||||
|
|
@ -61,7 +72,7 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo
|
|||
public override float DamagePerSecond => Damage / Cooldown;
|
||||
public override float RangeDamagePerSecond(float range)
|
||||
{
|
||||
return Damage * _data.DamageCurve.Evaluate(saturate(unlerp(MinRange, Range, range))) / Cooldown;
|
||||
return InstantWeaponData.RangeDamagePerSecond(_data.DamageCurve, Damage, MinRange, Range, Cooldown, range);
|
||||
}
|
||||
|
||||
public override int Ammo
|
||||
|
|
@ -129,9 +140,9 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo
|
|||
BurstTime = Evaluate(_data.BurstTime);
|
||||
Cooldown = Evaluate(_data.Cooldown);
|
||||
|
||||
Damage /= (int) BurstCount;
|
||||
Heat /= (int) BurstCount;
|
||||
Energy /= (int) BurstCount;
|
||||
Damage = InstantWeaponData.PerRound(Damage, BurstCount);
|
||||
Heat = InstantWeaponData.PerRound(Heat, BurstCount);
|
||||
Energy = InstantWeaponData.PerRound(Energy, BurstCount);
|
||||
}
|
||||
|
||||
// Cut 4 (docs/stats-and-power-cut.md §7 Q4): capacity and rate, resolved fresh rather than off the cached
|
||||
|
|
|
|||
|
|
@ -71,6 +71,13 @@ public abstract class WeaponData : BehaviorData
|
|||
// weapon-tracking-authored). Unauthored is a mount that follows anything: FireControl.PMount reads +infinity as 1.
|
||||
[Inspectable, JsonProperty("tracking"), Key(33), RuntimeInspectable]
|
||||
public PerformanceStat Tracking = new PerformanceStat { Min = float.PositiveInfinity, Max = float.PositiveInfinity };
|
||||
|
||||
// What an observer who believes this is the design measures it by (aetheria-ew, ew-claim-weapon-measure-ruled): the
|
||||
// design's rated figures, each stat's Max (what a stat with no terms resolves to), through the same formula the
|
||||
// live weapon's RangeDamagePerSecond runs over its evaluated stats. Never the carrier's quality, heat, wear or power.
|
||||
public abstract float RatedRangeDamagePerSecond(float range);
|
||||
[IgnoreMember]
|
||||
public float RatedRange => Range.Max;
|
||||
}
|
||||
|
||||
public abstract class Weapon : Behavior, IActivatedBehavior
|
||||
|
|
|
|||
|
|
@ -103,7 +103,27 @@ public abstract class Entity
|
|||
// each equipped suppressor folds the strength in equipment order and pays for what it alone removed.
|
||||
foreach (var suppressor in GetBehaviors<LaunchSuppressor>())
|
||||
strength = suppressor.Suppress(channel, strength);
|
||||
_visibilitySources[source] = new Signature(channel, strength, item);
|
||||
_visibilitySources[source] = new Signature(channel, strength, item, ClaimOf(item));
|
||||
}
|
||||
|
||||
// What this ship's acts claim (aetheria-ew, lie-is-own-emission, classify-from-belief): per equipped item, the
|
||||
// equippable class every act of that item is stamped with (SetSignature, Emit, ReceivePaint). The hull is the
|
||||
// item EquippedHull, so its claim lives in the same slot as every other: the hull class its own acts carry and
|
||||
// the profile an observer derives (ClaimedHull) are one value. The truth unless claimed otherwise. Claim is the
|
||||
// one write port; its production writer is the EW behaviour of cut ew-masquerade, tests only until then.
|
||||
// Deactivate clears the slots. Aim, damage and physics keep the truth.
|
||||
private readonly Dictionary<EquippedItem, EquippableItemData> _itemClaims = new Dictionary<EquippedItem, EquippableItemData>();
|
||||
public HullData ClaimedHull => (ClaimOf(EquippedHull) as HullData) ?? HullData;
|
||||
public EquippableItemData ClaimOf(EquippedItem item) =>
|
||||
item == null ? null : _itemClaims.TryGetValue(item, out var claimed) ? claimed : item.Data;
|
||||
internal void Claim(HullData hull) => Claim(EquippedHull, hull);
|
||||
internal void Claim(EquippedItem item, EquippableItemData claimed)
|
||||
{
|
||||
// One hull class in the book: a hull class is claimed by the hull and only by the hull, whichever side asks.
|
||||
if (claimed != null && (item == EquippedHull) != (claimed is HullData))
|
||||
throw new ArgumentException("Only the hull claims a hull class, and the hull claims nothing else.", nameof(claimed));
|
||||
if (claimed == null) _itemClaims.Remove(item);
|
||||
else _itemClaims[item] = claimed;
|
||||
}
|
||||
public readonly ReactiveDictionary<Entity, float> EntityInfoGathered = new ReactiveDictionary<Entity, float>();
|
||||
|
||||
|
|
@ -868,6 +888,7 @@ public abstract class Entity
|
|||
_pingReturns.Clear();
|
||||
_emissions.Clear();
|
||||
_reveals.Clear();
|
||||
_itemClaims.Clear();
|
||||
}
|
||||
|
||||
// Zone.Update runs this on every entity before any entity updates, so a paint made during tick N is published
|
||||
|
|
@ -880,7 +901,7 @@ public abstract class Entity
|
|||
foreach (var paint in _paintInbox)
|
||||
{
|
||||
_incomingPaints.Add(paint.Warning);
|
||||
RecordReveal(paint.Emitter, paint.Item, SignatureChannel.Emission);
|
||||
RecordReveal(paint.Emitter, paint.Item, SignatureChannel.Emission, paint.Warning.EmitterClass);
|
||||
}
|
||||
_paintInbox.Clear();
|
||||
_pingReturns.Clear();
|
||||
|
|
@ -900,16 +921,17 @@ public abstract class Entity
|
|||
internal void Emit(EquippedItem item, float hearRange)
|
||||
{
|
||||
if (!_active) return;
|
||||
_emissions.Add(new ActiveEmission(item, hearRange));
|
||||
_emissions.Add(new ActiveEmission(item, hearRange, ClaimOf(item)));
|
||||
}
|
||||
|
||||
// The callers are Sensor.Execute (a Search paint, Strength 0) and LockWeapon.Execute (a Track paint, Strength
|
||||
// the emitter's own Lock at that paint). The emitter's class is the item's data (null with no item: a
|
||||
// consumable's effect, which has no item and so is never revealed).
|
||||
// the emitter's own Lock at that paint). The emitter's class is the class its ship claims for the item (ClaimOf: the
|
||||
// item's data unless claimed otherwise; null with no item: a consumable's effect, which has no item and so is never
|
||||
// revealed).
|
||||
internal void ReceivePaint(Entity emitter, EquippedItem item, PaintMode mode, float strength)
|
||||
{
|
||||
if (!_active) return;
|
||||
_paintInbox.Add(new PendingPaint(new PaintWarning(normalize((emitter.Position - Position).xz), item?.Data, mode, strength), emitter, item));
|
||||
_paintInbox.Add(new PendingPaint(new PaintWarning(normalize((emitter.Position - Position).xz), emitter.ClaimOf(item), mode, strength), emitter, item));
|
||||
}
|
||||
|
||||
// The reveal book's one way in and its one membership gate (callers: Sense and PublishInbox, both in this file):
|
||||
|
|
@ -917,13 +939,14 @@ public abstract class Entity
|
|||
// radiation, a consumable) reveals nothing, and neither does a subject that is not in the zone: a departed
|
||||
// subject's entries are never created or refreshed, from any caller, and neither does an item this observer
|
||||
// perceives destroyed (PerceivesDestroyed). A later act of the same item replaces the
|
||||
// earlier one.
|
||||
internal void RecordReveal(Entity subject, EquippedItem item, SignatureChannel channel)
|
||||
// earlier one. `claimed` is the class the carrier (signature, emission or paint) stamped: what this observer's
|
||||
// classifying readers will believe of the act; `item` stays the aim and book key.
|
||||
internal void RecordReveal(Entity subject, EquippedItem item, SignatureChannel channel, EquippableItemData claimed)
|
||||
{
|
||||
if (!_active || item == null || !Zone.Entities.Contains(subject) || PerceivesDestroyed(subject, item)) return;
|
||||
if (!_reveals.TryGetValue(subject, out var book))
|
||||
_reveals[subject] = book = new Dictionary<EquippedItem, Reveal>();
|
||||
book[item] = new Reveal(item, channel, normalize((subject.Position - Position).xz), Zone.Time);
|
||||
book[item] = new Reveal(item, channel, normalize((subject.Position - Position).xz), Zone.Time, claimed);
|
||||
}
|
||||
|
||||
// This observer resolves `subject`'s gear (ContactOf's Identified gate) and `item` of it is destroyed: the one rule
|
||||
|
|
@ -955,6 +978,31 @@ public abstract class Entity
|
|||
public IEnumerable<Reveal> RevealsOf(Entity subject) =>
|
||||
_reveals.TryGetValue(subject, out var book) ? book.Values : Enumerable.Empty<Reveal>();
|
||||
|
||||
// The weapons this observer believes `subject` has: each reveal whose claimed class carries a weapon behaviour, in the
|
||||
// subject's equipment order (never the book's slot order, which follows when reveals were made and ended). The one
|
||||
// classifying read of the book for weapons, shared by the enemy schematic and Combat (aetheria-ew, classify-from-belief);
|
||||
// a gun claimed as a non-weapon is not listed, though RevealsOf holds it for aim.
|
||||
public IEnumerable<Reveal> RevealedWeapons(Entity subject) =>
|
||||
RevealsOf(subject).Where(r => r.Claimed != null && r.Claimed.Behaviors.Any(b => b is WeaponData))
|
||||
.OrderBy(r => FittedIndex(subject, r.Item)).ThenBy(r => r.Item.EquippableItem.Lot);
|
||||
|
||||
// A revealed item's place in the subject's equipment; gear the subject no longer carries follows all fitted gear
|
||||
// (ties among it fall to the lot, a stable id), never ahead of it.
|
||||
private static int FittedIndex(Entity subject, EquippedItem item)
|
||||
{
|
||||
var index = subject.Equipment.IndexOf(item);
|
||||
return index < 0 ? int.MaxValue : index;
|
||||
}
|
||||
|
||||
// The weapon rows of `subject`'s schematic as this ship reads it: its own equipment when `subject` is this ship (each
|
||||
// row its own data and live range, nothing to doubt about your own ship), otherwise RevealedWeapons (each row the
|
||||
// claimed class and that design's rated range). The one owner of which rows, which class and which range an
|
||||
// enemy schematic shows; the Unity SchematicDisplay renders it.
|
||||
public IEnumerable<SchematicRow> SchematicRows(Entity subject) =>
|
||||
subject == this
|
||||
? subject.Equipment.Where(i => i.Behaviors.Any(b => b.Data is WeaponData)).Select(i => new SchematicRow(i, i.Data, true))
|
||||
: RevealedWeapons(subject).Select(r => new SchematicRow(r.Item, r.Claimed, false));
|
||||
|
||||
// Whether this observer's book for `subject` holds `item`: the book's membership, no live gear read.
|
||||
internal bool HasReveal(Entity subject, EquippedItem item) =>
|
||||
_reveals.TryGetValue(subject, out var book) && book.ContainsKey(item);
|
||||
|
|
@ -976,7 +1024,9 @@ public abstract class Entity
|
|||
var remembered = any && Zone.Time - freshest.Time < settings.BearingMemory;
|
||||
var detected = tracked || remembered;
|
||||
var bearing = tracked ? normalize((subject.Position - Position).xz) : remembered ? freshest.Bearing : default;
|
||||
return new Contact(detected, tracked, detected && any, info >= settings.TargetGearInfoThreshold, bearing, info);
|
||||
var identified = info >= settings.TargetGearInfoThreshold;
|
||||
// Profile is the one derivation of the hull this observer believes (no stored belief until the judge, ew-masquerade).
|
||||
return new Contact(detected, tracked, detected && any, identified, bearing, info, identified ? subject.ClaimedHull : null);
|
||||
}
|
||||
|
||||
// What this observer's instruments receive of other ships' acts this tick (Zone.Update calls it once, after every
|
||||
|
|
@ -1000,7 +1050,7 @@ public abstract class Entity
|
|||
{
|
||||
if (signature.Item == null) continue;
|
||||
if (perSignature < 0f) perSignature = PassiveInfo(other.Position.xz, 1f);
|
||||
if (signature.Strength * perSignature > threshold) RecordReveal(other, signature.Item, signature.Channel);
|
||||
if (signature.Strength * perSignature > threshold) RecordReveal(other, signature.Item, signature.Channel, signature.Claimed);
|
||||
}
|
||||
// Every emission this ship made this tick that reaches this observer is revealed, bearing only (ruling
|
||||
// sensor-stat-set), through RecordReveal's one gate. It feeds no track.
|
||||
|
|
@ -1011,7 +1061,7 @@ public abstract class Entity
|
|||
for (var j = 0; j < other._emissions.Count; j++)
|
||||
{
|
||||
var emission = other._emissions[j];
|
||||
if (planar <= emission.HearRange * hearing * obscuration) RecordReveal(other, emission.Item, SignatureChannel.Emission);
|
||||
if (planar <= emission.HearRange * hearing * obscuration) RecordReveal(other, emission.Item, SignatureChannel.Emission, emission.Claimed);
|
||||
}
|
||||
}
|
||||
// The observer resolves this ship's gear (PerceivesDestroyed), so it perceives an item of it destroyed and
|
||||
|
|
|
|||
44
Assets/Scripts/ServerShared/Scenarios/LockOn.cs
Normal file
44
Assets/Scripts/ServerShared/Scenarios/LockOn.cs
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
using static CultMath.math;
|
||||
using static ItemRotation;
|
||||
|
||||
public sealed class LockOn : Scenario
|
||||
{
|
||||
public override string Name => "Lock On";
|
||||
public override string Brief => "A hostile closes from out of sight, finds you and locks. Verify: no warning at first; once it locks, one " +
|
||||
"indicator appears toward it and tightens as the lock grows; break line of sight or range and it vanishes; " +
|
||||
"your own ping shows no incoming paint on its side, only the lock does.";
|
||||
public override uint Seed => 113;
|
||||
public override bool Ambient => false;
|
||||
|
||||
// The hostile starts about 4000 m off, to the side of the player's axis, facing the player. At that range its passive
|
||||
// sensors hold no track of the player (the test asks the sim's own PassiveInfo against the detection threshold); the
|
||||
// player sees the hostile a little sooner, at about tick 20. The hostile closes, sees the player some eight seconds in,
|
||||
// and locks two ticks later. At Duel's 900 m (and at 600 m) it sees and locks within a second.
|
||||
public static readonly CultMath.float2 HostileAt = float2(1500, 3700);
|
||||
|
||||
public override Galaxy Generate(GalaxyStage stage) => stage.Prelude(DemoTerminus.Cast);
|
||||
|
||||
public override void Stage(ScenarioStage stage)
|
||||
{
|
||||
var fighter = stage.Fit("Longinus",
|
||||
("Cockpit 2x2", int2(2, 6), None),
|
||||
("Large Drive", int2(1, 0), Reversed),
|
||||
("Large Drive", int2(3, 0), Reversed),
|
||||
("Talaria", int2(2, 14), CounterClockwise),
|
||||
("Talaria", int2(3, 14), Clockwise),
|
||||
("Core Power", int2(2, 4), None),
|
||||
("GT 3K", int2(0, 5), None),
|
||||
("GT 3K", int2(5, 5), None),
|
||||
("FastBlast+-", int2(1, 8), None),
|
||||
("FastBlast+-", int2(4, 8), None),
|
||||
("Iapyx", int2(2, 2), CounterClockwise),
|
||||
("Iapyx", int2(3, 2), Clockwise),
|
||||
("not if i see you first", int2(3, 10), None),
|
||||
("PotaT+-", int2(1, 5), None),
|
||||
("Fire Control Array", int2(1, 6), Clockwise),
|
||||
("Store-All Plus", int2(2, 8), None));
|
||||
|
||||
stage.Player(fighter, float2(0, 0), facing: float2(0, 1));
|
||||
stage.Place(fighter, HostileAt, facing: normalize(-HostileAt), stance: ScenarioStance.Hostile, piloted: true);
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/ServerShared/Scenarios/LockOn.cs.meta
Normal file
11
Assets/Scripts/ServerShared/Scenarios/LockOn.cs.meta
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 3f0b6d2a9c7e4a1b8d5e2c6f7a9b0c14
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -9,6 +9,6 @@ public static class Scenarios
|
|||
|
||||
public static readonly Scenario[] Tests =
|
||||
{
|
||||
new DjinniShakedown(), new StarvedReactor(), new Arcs(), new Duel(), new LauncherAngles(), new WeaponFeel(), new FusedRounds(), new RefusedRounds(), new AiFusedDiscipline(), new LongHaul(), new Pursuit(), new Crossing(), new Belt()
|
||||
new DjinniShakedown(), new StarvedReactor(), new Arcs(), new Duel(), new LauncherAngles(), new WeaponFeel(), new FusedRounds(), new RefusedRounds(), new AiFusedDiscipline(), new LongHaul(), new Pursuit(), new Crossing(), new Belt(), new LockOn()
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
* 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.Linq;
|
||||
using CultMath;
|
||||
|
||||
// Light signatures split by channel (ruling visibility-per-capability-reveal): an observer learns what a ship
|
||||
|
|
@ -20,15 +21,19 @@ public readonly struct Signature
|
|||
public readonly SignatureChannel Channel;
|
||||
public readonly float Strength;
|
||||
public readonly EquippedItem Item;
|
||||
// The class this act claims: the true item's Data unless its ship's EW claims otherwise (aetheria-ew,
|
||||
// classify-from-belief). Stamped by Entity.SetSignature from the ship's claim.
|
||||
public readonly EquippableItemData Claimed;
|
||||
|
||||
public Signature(SignatureChannel channel, float strength, EquippedItem item)
|
||||
public Signature(SignatureChannel channel, float strength, EquippedItem item, EquippableItemData claimed)
|
||||
{
|
||||
Channel = channel;
|
||||
Strength = strength;
|
||||
Item = item;
|
||||
Claimed = claimed;
|
||||
}
|
||||
|
||||
public Signature WithStrength(float strength) => new Signature(Channel, strength, Item);
|
||||
public Signature WithStrength(float strength) => new Signature(Channel, strength, Item, Claimed);
|
||||
}
|
||||
|
||||
// What a painted ship learns of a beam on it (ruling sensor-stat-set item 4): a Search paint is a ping sweeping
|
||||
|
|
@ -64,11 +69,14 @@ public readonly struct ActiveEmission
|
|||
{
|
||||
public readonly EquippedItem Item;
|
||||
public readonly float HearRange;
|
||||
// The class this emission claims (see Signature.Claimed); stamped by Entity.Emit.
|
||||
public readonly EquippableItemData Claimed;
|
||||
|
||||
public ActiveEmission(EquippedItem item, float hearRange)
|
||||
public ActiveEmission(EquippedItem item, float hearRange, EquippableItemData claimed)
|
||||
{
|
||||
Item = item;
|
||||
HearRange = hearRange;
|
||||
Claimed = claimed;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -90,23 +98,53 @@ public readonly struct PaintWarning
|
|||
|
||||
// What an observer has seen `Item` do (ruling visibility-per-capability-reveal): the channel it showed on, the
|
||||
// bearing at that act and the zone clock then. Held per (subject, item) in the observer's Entity; the latest act
|
||||
// replaces the earlier one. Position is never carried: only the track gives it.
|
||||
// replaces the earlier one. Position is never carried: only the track gives it. `Item` stays the true item: it is the
|
||||
// book's key and what aim reads. `Claimed` is the class the act claimed, and is what every classifying reader reads
|
||||
// (aetheria-ew, classify-from-belief).
|
||||
public readonly struct Reveal
|
||||
{
|
||||
public readonly EquippedItem Item;
|
||||
public readonly EquippableItemData Claimed;
|
||||
public readonly SignatureChannel Channel;
|
||||
public readonly float2 Bearing;
|
||||
public readonly float Time;
|
||||
|
||||
public Reveal(EquippedItem item, SignatureChannel channel, float2 bearing, float time)
|
||||
public Reveal(EquippedItem item, SignatureChannel channel, float2 bearing, float time, EquippableItemData claimed)
|
||||
{
|
||||
Item = item;
|
||||
Claimed = claimed;
|
||||
Channel = channel;
|
||||
Bearing = bearing;
|
||||
Time = time;
|
||||
}
|
||||
}
|
||||
|
||||
// One weapon row of a ship's schematic as its viewer is entitled to read it (aetheria-ew, classify-from-belief; ruling
|
||||
// ew-claim-weapon-measure-ruled). Made only by Entity.SchematicRows. `Item` is the true item: the selection mark's key and,
|
||||
// on the viewer's own ship, the live gear. `Claimed` is the class the row shows. The range label is the live range on
|
||||
// the viewer's own ship and the claimed design's rated range on any other; the viewer never sees a target weapon's
|
||||
// quality, wear or power. A renderer shows these and decides nothing.
|
||||
public readonly struct SchematicRow
|
||||
{
|
||||
public readonly EquippedItem Item;
|
||||
public readonly EquippableItemData Claimed;
|
||||
private readonly bool _own;
|
||||
|
||||
internal SchematicRow(EquippedItem item, EquippableItemData claimed, bool own)
|
||||
{
|
||||
Item = item;
|
||||
Claimed = claimed;
|
||||
_own = own;
|
||||
}
|
||||
|
||||
// What a renderer compares to know the rows changed. Range is a reading, not part of the key.
|
||||
public (EquippedItem, EquippableItemData) Key => (Item, Claimed);
|
||||
|
||||
public float Range => _own
|
||||
? Item.Behaviors.OfType<Weapon>().First().Range
|
||||
: Claimed.Behaviors.OfType<WeaponData>().First().RatedRange;
|
||||
}
|
||||
|
||||
// A subject's removal from a zone as the zone recorded it (death, dock, warp, any removal): where it was and how
|
||||
// bright its light was (its Visibility at removal). Judged by each observer's Sense under the
|
||||
// light-act gate; notification only, one tick long, it decides nothing.
|
||||
|
|
@ -127,7 +165,9 @@ public readonly struct Departure
|
|||
// How much an observer holds of one subject, derived on read by Entity.ContactOf from its track, its reveals and
|
||||
// the zone clock. Detected: tracked, or a reveal younger than GameplaySettings.BearingMemory. Tracked: exactly
|
||||
// membership of VisibleEntities. Classified: detected, with any reveal in the book. Identified: the track has reached
|
||||
// TargetGearInfoThreshold. Bearing is the true bearing while tracked, else the freshest reveal's.
|
||||
// TargetGearInfoThreshold, and holds a hull belief: Profile, the hull class this observer believes the subject is (null
|
||||
// below Identified; aetheria-ew, classify-from-belief; seen-through arrives with the judge). Bearing is the true
|
||||
// bearing while tracked, else the freshest reveal's.
|
||||
public readonly struct Contact
|
||||
{
|
||||
public readonly bool Detected;
|
||||
|
|
@ -136,9 +176,11 @@ public readonly struct Contact
|
|||
public readonly bool Identified;
|
||||
public readonly float2 Bearing;
|
||||
public readonly float Info;
|
||||
public readonly HullData Profile;
|
||||
|
||||
public Contact(bool detected, bool tracked, bool classified, bool identified, float2 bearing, float info)
|
||||
public Contact(bool detected, bool tracked, bool classified, bool identified, float2 bearing, float info, HullData profile)
|
||||
{
|
||||
Profile = profile;
|
||||
Detected = detected;
|
||||
Tracked = tracked;
|
||||
Classified = classified;
|
||||
|
|
|
|||
|
|
@ -394,6 +394,10 @@ public class Zone
|
|||
// After StepClouds and before FireControl.Step: each floating item drifts as a KinematicBody on the zone's force
|
||||
// and is removed once its lifetime has run out. A held item is neither integrated nor expired: it is placed
|
||||
// between where it was claimed and its holder, now that the entities have moved.
|
||||
// The curve a held item rides from where it was claimed to its holder: the one owner, which the test scene's
|
||||
// stand-in sim and tendril call rather than spell again.
|
||||
public static float HeldPull(float progress) => smoothstep(0f, 1f, progress);
|
||||
|
||||
private void StepFloatingItems(float deltaTime)
|
||||
{
|
||||
var settings = _itemManager.GameplaySettings;
|
||||
|
|
@ -401,7 +405,7 @@ public class Zone
|
|||
{
|
||||
if (floating.Holder != null)
|
||||
{
|
||||
floating.Body.Position = lerp(floating.HeldFrom, floating.Holder.Position.xz, smoothstep(0f, 1f, floating.HeldProgress));
|
||||
floating.Body.Position = lerp(floating.HeldFrom, floating.Holder.Position.xz, HeldPull(floating.HeldProgress));
|
||||
continue;
|
||||
}
|
||||
floating.Body.Step(deltaTime, GetForce(floating.Body.Position), settings);
|
||||
|
|
|
|||
|
|
@ -26,6 +26,10 @@ public class FieldTester : MonoBehaviour
|
|||
public Camera Camera;
|
||||
public FieldDriver TestField;
|
||||
public PropertiesPanel Properties;
|
||||
// The scene's slow viewing timings for a grab, published as the events' phase durations.
|
||||
public float GrabExtendTime;
|
||||
public float GrabEnvelopTime;
|
||||
public float GrabPullTime;
|
||||
|
||||
private AetheriaInput _input;
|
||||
private bool _forceThrust;
|
||||
|
|
@ -49,6 +53,14 @@ public class FieldTester : MonoBehaviour
|
|||
|
||||
private List<(float lerp, float time, Transform transform)> _pickups = new List<(float lerp, float time, Transform transform)>();
|
||||
|
||||
// This scene stands in for the simulation, so it owns its pickup's grab timeline and pose: one grab at a time.
|
||||
private bool _grabbing;
|
||||
private int _grabIndex;
|
||||
private float _grabElapsed;
|
||||
private float3 _grabStart;
|
||||
private bool _envelopPublished;
|
||||
private bool _pullPublished;
|
||||
|
||||
private void Start()
|
||||
{
|
||||
_input = new AetheriaInput();
|
||||
|
|
@ -59,10 +71,7 @@ public class FieldTester : MonoBehaviour
|
|||
if (binder == null)
|
||||
Debug.LogError("FieldTester expects a CapabilityPresentationBinder on TestField's GameObject; no capability events will reach the field.");
|
||||
else
|
||||
binder.Bind(_capabilityEvents,
|
||||
grabTargetResolver: id => (int) id.Value >= 0 && (int) id.Value < _pickups.Count ? _pickups[(int) id.Value].transform : null,
|
||||
grabInitialVelocityResolver: id => Vector3.forward *
|
||||
(PickupTravelDistance / lerp(PickupTravelTimeMin, PickupTravelTimeMax, _pickups[(int) id.Value].lerp)));
|
||||
binder.Bind(_capabilityEvents);
|
||||
|
||||
// Impact absorption (fork L): what used to be FieldDriver's own click handler is now this
|
||||
// scene's stand-in for whatever will eventually detect a hit and publish AbsorbEvent.
|
||||
|
|
@ -99,12 +108,16 @@ public class FieldTester : MonoBehaviour
|
|||
var click = pickup.GetComponent<ClickableCollider>();
|
||||
click.OnClick += (collider, data, ray, hit) =>
|
||||
{
|
||||
if (!TestField.CanGrab) return;
|
||||
if (_grabbing) return;
|
||||
click.Clear();
|
||||
var p = _pickups[i1];
|
||||
_capabilityEvents.PublishGrab(new GrabEvent(new FloatingBodyId((uint) i1), GrabPhase.Started, p.transform.position.ToCultMath(), 0));
|
||||
p.transform = null;
|
||||
_pickups[i1] = p;
|
||||
_grabbing = true;
|
||||
_grabIndex = i1;
|
||||
_grabElapsed = 0;
|
||||
_envelopPublished = _pullPublished = false;
|
||||
_grabStart = _pickups[i1].transform.position.ToCultMath();
|
||||
var id = new FloatingBodyId((uint) i1);
|
||||
_capabilityEvents.PublishGrab(new GrabEvent(id, GrabPhase.Started, _grabStart, 0));
|
||||
_capabilityEvents.PublishGrab(new GrabEvent(id, GrabPhase.Extend, _grabStart, GrabExtendTime));
|
||||
};
|
||||
|
||||
pickup.ScanLabelContainer.gameObject.SetActive(false);
|
||||
|
|
@ -117,6 +130,45 @@ public class FieldTester : MonoBehaviour
|
|||
}
|
||||
}
|
||||
|
||||
// Publishes Envelop and Pull once each at their cumulative boundaries, holds the pickup still until the pull and
|
||||
// then moves it along the same smoothstep as Zone's held pose, and ends with Completed and the pickup's removal.
|
||||
private void AdvanceGrab()
|
||||
{
|
||||
_grabElapsed += Time.deltaTime;
|
||||
var id = new FloatingBodyId((uint) _grabIndex);
|
||||
var pullStart = GrabExtendTime + GrabEnvelopTime;
|
||||
if (!_envelopPublished && _grabElapsed >= GrabExtendTime)
|
||||
{
|
||||
_envelopPublished = true;
|
||||
_capabilityEvents.PublishGrab(new GrabEvent(id, GrabPhase.Envelop, _grabStart, GrabEnvelopTime));
|
||||
}
|
||||
if (!_pullPublished && _grabElapsed >= pullStart)
|
||||
{
|
||||
_pullPublished = true;
|
||||
_capabilityEvents.PublishGrab(new GrabEvent(id, GrabPhase.Pull, _grabStart, GrabPullTime));
|
||||
}
|
||||
var pickup = _pickups[_grabIndex].transform;
|
||||
pickup.position = PulledPosition(_grabStart, TestField.transform.position.ToCultMath(), _grabElapsed,
|
||||
GrabExtendTime, GrabEnvelopTime, GrabPullTime).ToUnity();
|
||||
if (_pullPublished && PullProgress(_grabElapsed, GrabExtendTime, GrabEnvelopTime, GrabPullTime) >= 1)
|
||||
{
|
||||
_capabilityEvents.PublishGrab(new GrabEvent(id, GrabPhase.Completed, pickup.position.ToCultMath(), 0));
|
||||
Destroy(pickup.gameObject);
|
||||
var p = _pickups[_grabIndex];
|
||||
p.transform = null;
|
||||
_pickups[_grabIndex] = p;
|
||||
_grabbing = false;
|
||||
}
|
||||
}
|
||||
|
||||
// How far along the pull a grab is `elapsed` seconds in: 0 through Extend and Envelop, then 0..1 over the pull.
|
||||
public static float PullProgress(float elapsed, float extend, float envelop, float pull) =>
|
||||
saturate((elapsed - extend - envelop) / max(pull, 1e-4f));
|
||||
|
||||
// Where the stand-in sim holds its pickup: still through Extend and Envelop, then along Zone's held-pose curve.
|
||||
public static float3 PulledPosition(float3 start, float3 holder, float elapsed, float extend, float envelop, float pull) =>
|
||||
lerp(start, holder, Zone.HeldPull(PullProgress(elapsed, extend, envelop, pull)));
|
||||
|
||||
private void Update()
|
||||
{
|
||||
var move = _forceThrust ? float2(0,1) : _input.Player.Move.ReadValue<Vector2>().ToCultMath();
|
||||
|
|
@ -144,10 +196,12 @@ public class FieldTester : MonoBehaviour
|
|||
|
||||
_capabilityEvents.PublishThrust(new ThrustEvent(_push, min(length(_push), 1), float2(_frontTwist, _rearTwist)));
|
||||
|
||||
if (_grabbing) AdvanceGrab();
|
||||
|
||||
for (var i = 0; i < _pickups.Count; i++)
|
||||
{
|
||||
var (l, time, t) = _pickups[i];
|
||||
if (t == null) continue;
|
||||
if (t == null || (_grabbing && i == _grabIndex)) continue;
|
||||
time = frac(time + Time.deltaTime / lerp(PickupTravelTimeMin, PickupTravelTimeMax, l));
|
||||
t.localScale = Vector3.one * lerp(PickupSizeMin, PickupSizeMax, l) * Zone.PowerPulse(time - .5f, ScaleExponent);
|
||||
t.position = new Vector3(t.position.x, t.position.y, (time - .5f) * PickupTravelDistance);
|
||||
|
|
|
|||
|
|
@ -58,7 +58,8 @@ public class SchematicDisplay : MonoBehaviour
|
|||
|
||||
public class SchematicDisplayItem
|
||||
{
|
||||
public EquippedItem Item;
|
||||
// The row Entity.SchematicRows derived: which item, which class and which range it shows.
|
||||
public SchematicRow Row;
|
||||
public SchematicListElement ListElement;
|
||||
public IProgressBehavior Cooldown;
|
||||
// public ItemUsage ItemUsage;
|
||||
|
|
@ -93,15 +94,12 @@ public class SchematicDisplay : MonoBehaviour
|
|||
CargoTemperatureLabel.text = "N/A";
|
||||
}
|
||||
|
||||
BuildRows(_enemy ? RevealedWeapons() : entity.Equipment.Where(IsWeapon));
|
||||
BuildRows(WeaponRows());
|
||||
}
|
||||
|
||||
private static bool IsWeapon(EquippedItem item) => item.Behaviors.Any(b => b.Data is WeaponData);
|
||||
|
||||
// An enemy schematic lists what `_player` has seen the ship's weapons do: the weapon items of the player's book on it,
|
||||
// in book order (ruling reveal-ends-on-perception), never the ship's live gear. The player's own schematic is its
|
||||
// equipment; there is nothing to reveal about your own ship.
|
||||
private IEnumerable<EquippedItem> RevealedWeapons() => _player.RevealsOf(_entity).Select(r => r.Item).Where(IsWeapon);
|
||||
// Which rows, which class and which range label are Entity.SchematicRows' decision (aetheria-ew, classify-from-belief);
|
||||
// this display only renders them.
|
||||
private IEnumerable<SchematicRow> WeaponRows() => (_player ?? _entity).SchematicRows(_entity);
|
||||
|
||||
private void ReturnRows()
|
||||
{
|
||||
|
|
@ -111,21 +109,21 @@ public class SchematicDisplay : MonoBehaviour
|
|||
_schematicItems = null;
|
||||
}
|
||||
|
||||
private void BuildRows(IEnumerable<EquippedItem> items)
|
||||
private void BuildRows(IEnumerable<SchematicRow> rows)
|
||||
{
|
||||
_schematicItems = items
|
||||
_schematicItems = rows
|
||||
.Select(x => new SchematicDisplayItem
|
||||
{
|
||||
Item = x,
|
||||
Row = x,
|
||||
ListElement = ListElementPrototype.Instantiate<SchematicListElement>(),
|
||||
Cooldown = _enemy ? null : (IProgressBehavior) x.Behaviors.FirstOrDefault(b=> b is IProgressBehavior),
|
||||
Cooldown = _enemy ? null : (IProgressBehavior) x.Item.Behaviors.FirstOrDefault(b=> b is IProgressBehavior),
|
||||
// ItemUsage = _enemy ? null : (ItemUsage) x.Behaviors.FirstOrDefault(b=> b is ItemUsage),
|
||||
Weapon = (Weapon) x.Behaviors.FirstOrDefault(b=>b is Weapon)
|
||||
Weapon = _enemy ? null : (Weapon) x.Item.Behaviors.FirstOrDefault(b=>b is Weapon)
|
||||
})
|
||||
.ToArray();
|
||||
foreach (var x in _schematicItems)
|
||||
{
|
||||
if(x.Item.Data is WeaponItemData weaponItemData)
|
||||
if(x.Row.Claimed is WeaponItemData weaponItemData)
|
||||
x.ListElement.ShowWeapon(weaponItemData);
|
||||
//x.ListElement.Label.text = x.Item.EquippableItem.Name;
|
||||
if (!_enemy)
|
||||
|
|
@ -197,10 +195,10 @@ public class SchematicDisplay : MonoBehaviour
|
|||
|
||||
// A reveal gained or ended after selection changes the rows with no event to hook: rebuild when the
|
||||
// revealed weapon set differs from the rows' items.
|
||||
if (_enemy && !RevealedWeapons().SequenceEqual(_schematicItems.Select(x => x.Item)))
|
||||
if (_enemy && !WeaponRows().Select(r => r.Key).SequenceEqual(_schematicItems.Select(x => x.Row.Key)))
|
||||
{
|
||||
ReturnRows();
|
||||
BuildRows(RevealedWeapons());
|
||||
BuildRows(WeaponRows());
|
||||
}
|
||||
|
||||
// Cut 2 (docs/fire-control-cut.md): marks whichever row is the player's current aim point.
|
||||
|
|
@ -208,7 +206,7 @@ public class SchematicDisplay : MonoBehaviour
|
|||
// as info decays, with no event to hook.
|
||||
if (_enemy && _schematicItems != null)
|
||||
foreach (var x in _schematicItems)
|
||||
x.ListElement.SetSelected(x.Item == _player.ResolvedTargetItem);
|
||||
x.ListElement.SetSelected(x.Row.Item == _player.ResolvedTargetItem);
|
||||
|
||||
if(VisibilityLabel && !_enemy)
|
||||
VisibilityLabel.text = ((int)_entity.Visibility).ToString();
|
||||
|
|
@ -226,11 +224,11 @@ public class SchematicDisplay : MonoBehaviour
|
|||
{
|
||||
if (!_enemy)
|
||||
{
|
||||
var itemData = _entity.ItemManager.GetData(x.Item.EquippableItem);
|
||||
x.ListElement.HeatFill.anchorMax = new Vector2(unlerp(itemData.MinimumTemperature, itemData.MaximumTemperature, x.Item.Temperature), 0);
|
||||
var itemData = _entity.ItemManager.GetData(x.Row.Item.EquippableItem);
|
||||
x.ListElement.HeatFill.anchorMax = new Vector2(unlerp(itemData.MinimumTemperature, itemData.MaximumTemperature, x.Row.Item.Temperature), 0);
|
||||
if (x.Cooldown != null)
|
||||
x.ListElement.CooldownFill.anchorMax = new Vector2(x.Cooldown.Progress, 1);
|
||||
x.ListElement.DurabilityLabel.text = $"{(int)(x.Item.EquippableItem.Durability / itemData.Durability * 100)}%";
|
||||
x.ListElement.DurabilityLabel.text = $"{(int)(x.Row.Item.EquippableItem.Durability / itemData.Durability * 100)}%";
|
||||
if (x.Weapon.WeaponData.AmmoType.IsSet())
|
||||
{
|
||||
if(x.Weapon.WeaponData.MagazineSize > 1)
|
||||
|
|
@ -240,11 +238,8 @@ public class SchematicDisplay : MonoBehaviour
|
|||
}
|
||||
}
|
||||
|
||||
if (x.Weapon.WeaponData != null)
|
||||
{
|
||||
if(x.ListElement.RangeLabel)
|
||||
x.ListElement.RangeLabel.text = ((int) x.Weapon.Range).ToString();
|
||||
}
|
||||
if (x.ListElement.RangeLabel)
|
||||
x.ListElement.RangeLabel.text = ((int) x.Row.Range).ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
BIN
GameData/Aetheria.cc
(Stored with Git LFS)
BIN
GameData/Aetheria.cc
(Stored with Git LFS)
Binary file not shown.
464
tests/Aetheria.Shared.Tests/ClaimTests.cs
Normal file
464
tests/Aetheria.Shared.Tests/ClaimTests.cs
Normal file
|
|
@ -0,0 +1,464 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using CultMath;
|
||||
using GameCult.Caching;
|
||||
using Xunit;
|
||||
using static CultMath.math;
|
||||
using float3 = CultMath.float3;
|
||||
|
||||
// Cut ew-claims-in-the-book (aetheria-ew, classify-from-belief, lie-is-own-emission, belief-one-writer): every act a ship
|
||||
// makes is stamped with the class its ship claims for the item (the true item's data unless claimed otherwise), the
|
||||
// observer's book keeps that claim beside the true item, and every classifying read takes the claim while aim keeps the
|
||||
// truth. The fixtures drive the production path: real ships fire, ping and lock inside Zone.Update.
|
||||
public sealed partial class ContactRevealTests
|
||||
{
|
||||
private EquippableItemData DataOf(string name) => (EquippableItemData) _items.ItemData.GetByName<ItemData>(name);
|
||||
|
||||
private static PerformanceStat Span(float min, float max) => new PerformanceStat { Min = min, Max = max };
|
||||
|
||||
// A damage curve that falls from 1 to .2 over the range span, so every range argument lands on a different factor.
|
||||
private static BezierCurve Falloff() => new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, .2f, 0, 0) } };
|
||||
|
||||
private static BezierCurve Flat() => new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } };
|
||||
|
||||
// ---- ClaimsDefaultToTheTruth ----
|
||||
|
||||
[Fact]
|
||||
public void ClaimsDefaultToTheTruth()
|
||||
{
|
||||
var e = Add(float3(30, 0, 40), new[] { "Gun" });
|
||||
var o = Add(float3(0, 0, 0), new[] { "Eye" });
|
||||
var gun = Guns(e)[0];
|
||||
Fire(e, gun);
|
||||
Assert.Same(gun.Data, Assert.Single(o.RevealsOf(e)).Claimed); // a fired gun claims its own design
|
||||
|
||||
// The hull belief: none below the gear threshold, the true hull at it (TargetGearInfoThreshold .8).
|
||||
o.EntityInfoGathered[e] = .79f;
|
||||
Assert.Null(o.ContactOf(e).Profile);
|
||||
o.EntityInfoGathered[e] = .8f;
|
||||
Assert.Same(e.HullData, o.ContactOf(e).Profile);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AHeardPingDefaultsToTheTruth()
|
||||
{
|
||||
var p = Add(float3(0, 0, 0), new[] { "Shout" });
|
||||
var hearer = Add(float3(149, 0, 0), new[] { "Eye" }); // off the beam: heard, never painted
|
||||
PingAt(p);
|
||||
Assert.Same(SensorItemOf(p).Data, Assert.Single(hearer.RevealsOf(p)).Claimed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void APaintDefaultsToTheTruth()
|
||||
{
|
||||
var (locker, target) = LockOn("Locker");
|
||||
Tick(.5f);
|
||||
Tick(.5f);
|
||||
var lockItem = locker.Equipment.Single(i => i.Behaviors.Any(b => b is LockWeapon));
|
||||
Assert.Same(lockItem.Data, Assert.Single(target.IncomingPaints).EmitterClass);
|
||||
Assert.Same(lockItem.Data, Assert.Single(target.RevealsOf(locker)).Claimed);
|
||||
}
|
||||
|
||||
// ---- AnItemClaimIsWhatTheBookSays ----
|
||||
|
||||
// The book keeps the true item as its key and the claim beside it; RevealedWeapons lists it by the claim, and aim
|
||||
// still opens on the true item. Mutation: stamp item.Data in SetSignature, or have RecordReveal store the true data.
|
||||
[Fact]
|
||||
public void AnItemClaimIsWhatTheBookSays()
|
||||
{
|
||||
var e = Add(float3(30, 0, 40), new[] { "Gun" });
|
||||
var o = Add(float3(0, 0, 0), new[] { "Eye" });
|
||||
var gun = Guns(e)[0];
|
||||
var other = DataOf("Beam");
|
||||
e.Claim(gun, other);
|
||||
|
||||
Fire(e, gun);
|
||||
|
||||
var reveal = Assert.Single(o.RevealsOf(e));
|
||||
Assert.Same(gun, reveal.Item);
|
||||
Assert.Same(other, reveal.Claimed);
|
||||
Assert.Same(gun, Assert.Single(o.RevealedWeapons(e)).Item);
|
||||
Assert.True(FireControl.IsRevealed(o, gun));
|
||||
}
|
||||
|
||||
// ---- AGunClaimedAsNonWeaponIsNotListed ----
|
||||
|
||||
[Fact]
|
||||
public void AGunClaimedAsNonWeaponIsNotListed()
|
||||
{
|
||||
var e = Add(float3(30, 0, 40), new[] { "Gun" });
|
||||
var o = Add(float3(0, 0, 0), new[] { "Eye" });
|
||||
var gun = Guns(e)[0];
|
||||
e.Claim(gun, DataOf("Eye"));
|
||||
|
||||
Fire(e, gun);
|
||||
|
||||
Assert.Empty(o.RevealedWeapons(e));
|
||||
Assert.Single(o.RevealsOf(e)); // the book still holds the gun, for aim
|
||||
Assert.True(FireControl.IsRevealed(o, gun));
|
||||
}
|
||||
|
||||
// ---- AHullClaimIsTheProfile ----
|
||||
|
||||
// Mutation: ContactOf reads subject.HullData, or reads the claim below the gear threshold.
|
||||
[Fact]
|
||||
public void AHullClaimIsTheProfile()
|
||||
{
|
||||
var e = Add(float3(30, 0, 40), new[] { "Gun" });
|
||||
var o = Add(float3(0, 0, 0), new[] { "Eye" });
|
||||
var truth = e.HullData;
|
||||
var hull = e.Hull;
|
||||
var decoy = new HullData { Name = "Decoy" };
|
||||
e.Claim(decoy);
|
||||
Fire(e, Guns(e)[0]);
|
||||
|
||||
o.EntityInfoGathered[e] = .79f;
|
||||
Assert.Null(o.ContactOf(e).Profile);
|
||||
o.EntityInfoGathered[e] = .8f;
|
||||
Assert.Same(decoy, o.ContactOf(e).Profile);
|
||||
Assert.Same(truth, e.HullData); // the truth is untouched: damage, shape and physics keep it
|
||||
Assert.Same(hull, e.Hull);
|
||||
}
|
||||
|
||||
// ---- APaintCarriesTheClaim ----
|
||||
|
||||
[Fact]
|
||||
public void APaintCarriesTheClaim()
|
||||
{
|
||||
var locker = Add(float3(0, 0, 0), new[] { "Locker" });
|
||||
var target = Add(float3(0, 0, 200));
|
||||
var lockItem = locker.Equipment.Single(i => i.Behaviors.Any(b => b is LockWeapon));
|
||||
var claimed = DataOf("Eye");
|
||||
locker.Claim(lockItem, claimed);
|
||||
locker.SetIff(target, true);
|
||||
locker.EntityInfoGathered[target] = 1f;
|
||||
locker.SetTarget(target);
|
||||
locker.Aim = normalize(target.Position - locker.Position);
|
||||
|
||||
Tick(.5f);
|
||||
Tick(.5f);
|
||||
|
||||
Assert.Same(claimed, Assert.Single(target.IncomingPaints).EmitterClass);
|
||||
var reveal = Assert.Single(target.RevealsOf(locker));
|
||||
Assert.Same(lockItem, reveal.Item);
|
||||
Assert.Same(claimed, reveal.Claimed);
|
||||
}
|
||||
|
||||
// The emission a ping makes is stamped by the ship that makes it. Mutation: Emit stamps item.Data.
|
||||
[Fact]
|
||||
public void AHeardPingCarriesTheClaim()
|
||||
{
|
||||
var p = Add(float3(0, 0, 0), new[] { "Shout" });
|
||||
var hearer = Add(float3(149, 0, 0), new[] { "Eye" }); // off the beam: heard, never painted
|
||||
var claimed = DataOf("Ear");
|
||||
p.Claim(SensorItemOf(p), claimed);
|
||||
|
||||
PingAt(p);
|
||||
|
||||
Assert.Same(claimed, Assert.Single(hearer.RevealsOf(p)).Claimed);
|
||||
}
|
||||
|
||||
// ---- AClaimEndsWithItsShip ----
|
||||
|
||||
[Fact]
|
||||
public void AClaimEndsWithItsShip()
|
||||
{
|
||||
var e = Add(float3(30, 0, 40), new[] { "Gun" });
|
||||
var gun = Guns(e)[0];
|
||||
e.Claim(new HullData { Name = "Decoy" });
|
||||
e.Claim(gun, DataOf("Eye"));
|
||||
|
||||
e.Deactivate();
|
||||
|
||||
Assert.Same(e.HullData, e.ClaimedHull);
|
||||
Assert.Same(gun.Data, e.ClaimOf(gun));
|
||||
}
|
||||
|
||||
// ---- Rated figures (ruling ew-claim-weapon-measure-ruled) ----
|
||||
|
||||
// A hull's claim is its own item's claim: the hull's acts (a Reflector's reflection, a launch flash, the hull as the
|
||||
// item of any signature) carry the class the profile reports, so the book never holds the true hull beside a decoy.
|
||||
// Mutation: ClaimedHull reads a slot of its own, or Claim(HullData) writes one.
|
||||
[Fact]
|
||||
public void AHullClaimReachesTheHullsOwnReveal()
|
||||
{
|
||||
var e = Add(float3(30, 0, 40), new[] { "Gun" });
|
||||
var o = Add(float3(0, 0, 0), new[] { "Eye" });
|
||||
var decoy = new HullData { Name = "Decoy" };
|
||||
e.Claim(decoy);
|
||||
Assert.Same(decoy, e.ClaimOf(e.EquippedHull));
|
||||
Assert.Same(decoy, e.ClaimedHull);
|
||||
|
||||
e.SetSignature(new object(), SignatureChannel.LaunchFlash, 1000000f, e.EquippedHull);
|
||||
Tick(.1f);
|
||||
|
||||
var reveal = Assert.Single(o.RevealsOf(e), r => r.Item == e.EquippedHull);
|
||||
Assert.Same(decoy, reveal.Claimed);
|
||||
o.EntityInfoGathered[e] = .8f;
|
||||
Assert.Same(reveal.Claimed, o.ContactOf(e).Profile);
|
||||
|
||||
e.Claim(null);
|
||||
Assert.Same(e.HullData, e.ClaimedHull);
|
||||
Assert.Same(e.HullData, e.ClaimOf(e.EquippedHull));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TheHullCanOnlyClaimToBeAHull()
|
||||
{
|
||||
var e = Add(float3(30, 0, 40), new[] { "Gun" });
|
||||
Assert.Throws<System.ArgumentException>(() => e.Claim(e.EquippedHull, DataOf("Eye")));
|
||||
Assert.Same(e.HullData, e.ClaimedHull);
|
||||
}
|
||||
|
||||
// ---- Rated figures (ruling ew-claim-weapon-measure-ruled) ----
|
||||
|
||||
// A design's rated figure is each stat's Max through the live weapon's own formula. Every stat is authored with
|
||||
// Min below Max, a falling curve and a MinRange above 0, so reading a Min, scaling the range argument, the wrong
|
||||
// stat or the wrong operator changes the answer. At 70 the range factor is (70 - 20) / (120 - 20) = .5.
|
||||
// Mutation: any rated override reads Range.Min or MinRange.Min or a Min, drops the burst divisor, the charge
|
||||
// multiplier or the charge time; the shared formula scales its range argument.
|
||||
[Fact]
|
||||
public void ARatedFigureIsTheDesignsMaxThroughTheSharedFormula()
|
||||
{
|
||||
var curve = Falloff();
|
||||
var half = curve.Evaluate(.5f);
|
||||
Assert.NotEqual(half, curve.Evaluate(1f)); // the Range.Min and halved-range mutants land elsewhere on the curve
|
||||
Assert.NotEqual(half, curve.Evaluate(.15f));
|
||||
Assert.NotEqual(half, curve.Evaluate(70f / 120f));
|
||||
|
||||
var instant = Spanned(new InstantWeaponData());
|
||||
Assert.Equal(100f / 2 * half / 2, instant.RatedRangeDamagePerSecond(70f), 4); // 100 / 2 rounds, curve, / 2 s
|
||||
Assert.Equal(120f, instant.RatedRange);
|
||||
|
||||
var charged = (ChargedWeaponData) Spanned(new ChargedWeaponData
|
||||
{
|
||||
ChargeTime = Span(1, 3), ChargeFiringDamageMultiplier = 2f
|
||||
});
|
||||
Assert.Equal(100f / 2 * 2 * half / (2 + 3), charged.RatedRangeDamagePerSecond(70f), 4);
|
||||
|
||||
var beam = Spanned(new ConstantWeaponData());
|
||||
Assert.Equal(100f * half, beam.RatedRangeDamagePerSecond(70f), 4);
|
||||
Assert.Equal(120f, beam.RatedRange);
|
||||
}
|
||||
|
||||
// The rated read and the live read are one formula: a nominal weapon's live figure is its design's rated figure,
|
||||
// for each kind, at ranges before, inside and past the falloff span, and equals the hand arithmetic.
|
||||
// Mutation: a rated override with its own arithmetic, or a live read that drifts from the shared formula (the
|
||||
// charged read's ChargeTime, the range, the curve).
|
||||
[Fact]
|
||||
public void ARatedFigureIsTheLiveFigureOfANominalWeapon()
|
||||
{
|
||||
var e = Add(float3(0, 0, 0), new[] { "SpanGun", "SpanCharged", "SpanBeam" });
|
||||
Tick(.1f);
|
||||
var curve = Falloff();
|
||||
var expected = new Dictionary<string, Func<float, float>>
|
||||
{
|
||||
["SpanGun"] = at => 100f / 2 * curve.Evaluate(Math.Clamp((at - 20f) / 100f, 0f, 1f)) / 2,
|
||||
["SpanCharged"] = at => 100f / 2 * 2 * curve.Evaluate(Math.Clamp((at - 20f) / 100f, 0f, 1f)) / (2 + 3),
|
||||
["SpanBeam"] = at => 100f * curve.Evaluate(Math.Clamp((at - 20f) / 100f, 0f, 1f))
|
||||
};
|
||||
foreach (var item in e.Equipment.Where(i => i.Behaviors.Any(b => b is Weapon)))
|
||||
{
|
||||
var live = item.Behaviors.OfType<Weapon>().Single();
|
||||
var rated = item.Data.Behaviors.OfType<WeaponData>().Single();
|
||||
foreach (var at in new[] { 10f, 70f, 110f, 500f })
|
||||
{
|
||||
Assert.Equal(expected[item.Data.Name](at), live.RangeDamagePerSecond(at), 3);
|
||||
Assert.Equal(live.RangeDamagePerSecond(at), rated.RatedRangeDamagePerSecond(at), 3);
|
||||
}
|
||||
}
|
||||
}
|
||||
// A charged weapon at full charge reads the same rated figure: the charge multiplier is applied once, in the formula
|
||||
// (UpdateStats lerps the shot's damage by charge, so the live reads start from the uncharged damage). At range 10 the
|
||||
// curve factor is 1: 100 / 2 rounds x 2 / (2 + 3) = 20. Mutation: the live reads pass the charge-lerped Damage.
|
||||
[Fact]
|
||||
public void AFullyChargedWeaponReadsItsRatedFigure()
|
||||
{
|
||||
var e = Add(float3(0, 0, 0), new[] { "SpanCharged" });
|
||||
Tick(.1f);
|
||||
var item = e.Equipment.Single(i => i.Data.Name == "SpanCharged");
|
||||
var live = (ChargedWeapon) item.Behaviors.Single(b => b is ChargedWeapon);
|
||||
var rated = item.Data.Behaviors.OfType<WeaponData>().Single();
|
||||
typeof(ChargedWeapon).GetField("_charge", BindingFlags.Instance | BindingFlags.NonPublic).SetValue(live, 1f);
|
||||
typeof(Weapon).GetMethod("UpdateStats", BindingFlags.Instance | BindingFlags.NonPublic).Invoke(live, null);
|
||||
|
||||
foreach (var at in new[] { 10f, 70f, 110f })
|
||||
Assert.Equal(rated.RatedRangeDamagePerSecond(at), live.RangeDamagePerSecond(at), 3);
|
||||
Assert.Equal(20f, live.DamagePerSecond, 3);
|
||||
Assert.Equal(20f, live.RangeDamagePerSecond(10f), 3);
|
||||
}
|
||||
|
||||
// ---- The schematic's rows (ruling ew-claim-weapon-measure-ruled; finding schematic-unreached) ----
|
||||
|
||||
// Wears a gun's Range to 95% (a shorter one is under the fuse arming distance and the gun is refused) so that, once it ticks, its live range (38) is not its design's rated range (40).
|
||||
private void WearRange(Entity ship, EquippedItem gun)
|
||||
{
|
||||
var data = (InstantWeaponData) gun.Data.Behaviors.Single(b => b is InstantWeaponData);
|
||||
ship.Resolver.AttachModifier(gun, data.Range, "worn", StatModifierType.Multiplier, .95f);
|
||||
}
|
||||
|
||||
// The fixture's guard, read once the gun has ticked: its live range is not its design's rated range.
|
||||
private static void AssertWorn(EquippedItem gun) =>
|
||||
Assert.Equal(38f, gun.Behaviors.OfType<Weapon>().Single().Range, 2);
|
||||
|
||||
// The unclaimed enemy row is the true gun, with the gun's own rated range (Gun: 40) though the gun is worn to a live
|
||||
// 38. Mutation: the row's Item.Data or the true live range stands in for the claim (also when Claimed == Data).
|
||||
[Fact]
|
||||
public void AnEnemySchematicRowDefaultsToTheTruth()
|
||||
{
|
||||
var e = Add(float3(30, 0, 40), new[] { "Gun" });
|
||||
var o = Add(float3(0, 0, 0), new[] { "Eye" });
|
||||
var gun = Guns(e)[0];
|
||||
WearRange(e, gun);
|
||||
Fire(e, gun);
|
||||
AssertWorn(gun);
|
||||
|
||||
var row = Assert.Single(o.SchematicRows(e));
|
||||
Assert.Same(gun, row.Item);
|
||||
Assert.Same(gun.Data, row.Claimed);
|
||||
Assert.Equal(40f, row.Range);
|
||||
Assert.NotEqual(gun.Behaviors.OfType<Weapon>().Single().Range, row.Range);
|
||||
}
|
||||
|
||||
// A weapon claimed as another weapon shows the claimed design's rated range (SpanGun: Range.Max 120), not the true
|
||||
// gun's 40. Mutation: Range reads the true item, or the claimed stat's Min (50), or scales the figure.
|
||||
[Fact]
|
||||
public void AWeaponClaimedAsAnotherWeaponShowsTheClaimedRatedRange()
|
||||
{
|
||||
var e = Add(float3(30, 0, 40), new[] { "Gun" });
|
||||
var o = Add(float3(0, 0, 0), new[] { "Eye" });
|
||||
var gun = Guns(e)[0];
|
||||
var other = DataOf("SpanGun");
|
||||
e.Claim(gun, other);
|
||||
Fire(e, gun);
|
||||
|
||||
var row = Assert.Single(o.SchematicRows(e));
|
||||
Assert.Same(gun, row.Item);
|
||||
Assert.Same(other, row.Claimed);
|
||||
Assert.Equal(120f, row.Range);
|
||||
}
|
||||
|
||||
// Mutation: the rows are the book's reveals (RevealsOf) or the subject's equipment.
|
||||
[Fact]
|
||||
public void AWeaponClaimedAsANonWeaponHasNoSchematicRow()
|
||||
{
|
||||
var e = Add(float3(30, 0, 40), new[] { "Gun" });
|
||||
var o = Add(float3(0, 0, 0), new[] { "Eye" });
|
||||
var gun = Guns(e)[0];
|
||||
e.Claim(gun, DataOf("Eye"));
|
||||
Fire(e, gun);
|
||||
|
||||
Assert.Empty(o.SchematicRows(e));
|
||||
Assert.Single(o.RevealsOf(e));
|
||||
}
|
||||
|
||||
// Unrevealed gear never appears on an enemy schematic: a ship that has not fired shows no row.
|
||||
[Fact]
|
||||
public void AnArmedShipThatNeverFiredHasNoEnemySchematicRow()
|
||||
{
|
||||
var e = Add(float3(30, 0, 40), new[] { "Gun" });
|
||||
var o = Add(float3(0, 0, 0), new[] { "Eye" });
|
||||
Tick(.1f);
|
||||
Assert.Empty(o.SchematicRows(e));
|
||||
}
|
||||
|
||||
// Your own schematic is your own gear whatever you claim: the true data, the live range, weapons only.
|
||||
// Mutation: own rows read ClaimOf, the rated range of the claim (SpanGun 120) or of the own design (40), or list non-weapons.
|
||||
[Fact]
|
||||
public void OwnSchematicRowsShowOwnTruth()
|
||||
{
|
||||
var e = Add(float3(30, 0, 40), new[] { "Gun" });
|
||||
var gun = Guns(e)[0];
|
||||
WearRange(e, gun);
|
||||
e.Claim(gun, DataOf("SpanGun"));
|
||||
Tick(.1f);
|
||||
AssertWorn(gun);
|
||||
|
||||
var row = Assert.Single(e.SchematicRows(e));
|
||||
Assert.Same(gun, row.Item);
|
||||
Assert.Same(gun.Data, row.Claimed);
|
||||
Assert.Equal(38f, row.Range, 2); // the live range of the worn gun, below its rated 40
|
||||
Assert.Equal(gun.Behaviors.OfType<Weapon>().Single().Range, row.Range);
|
||||
Assert.NotEqual(((WeaponData) gun.Data.Behaviors.Single(b => b is WeaponData)).RatedRange, row.Range);
|
||||
}
|
||||
|
||||
// The renderer rebuilds when the key differs: a claim that changes between two acts changes the key, and an
|
||||
// unchanged claim does not. Mutation: Key drops Claimed.
|
||||
[Fact]
|
||||
public void TheRebuildKeyChangesWhenAClaimChanges()
|
||||
{
|
||||
var e = Add(float3(30, 0, 40), new[] { "Gun" });
|
||||
var o = Add(float3(0, 0, 0), new[] { "Eye" });
|
||||
var gun = Guns(e)[0];
|
||||
Fire(e, gun);
|
||||
var before = o.SchematicRows(e).Select(r => r.Key).ToArray();
|
||||
Assert.Equal(before, o.SchematicRows(e).Select(r => r.Key).ToArray());
|
||||
|
||||
e.Claim(gun, DataOf("SpanGun"));
|
||||
e.SetSignature(new object(), SignatureChannel.LaunchFlash, 1000000f, gun);
|
||||
Tick(.1f);
|
||||
|
||||
var after = o.SchematicRows(e).Select(r => r.Key).ToArray();
|
||||
Assert.Single(after);
|
||||
Assert.NotEqual(before, after);
|
||||
Assert.Same(DataOf("SpanGun"), after[0].Item2);
|
||||
}
|
||||
|
||||
// Two guns of one claimed design are told apart by the key: a row set that swaps one gun for the other keeps the count and
|
||||
// the claims and changes only the item, and the renderer must still rebuild (the selection mark follows the item).
|
||||
// Mutation: Key drops Item.
|
||||
[Fact]
|
||||
public void TheRebuildKeyChangesWhenOnlyTheItemChanges()
|
||||
{
|
||||
var e = Add(float3(30, 0, 40), new[] { "Gun", "Gun" });
|
||||
var a = Add(float3(0, 0, 0), new[] { "Eye" });
|
||||
var b = Add(float3(0, 0, 5), new[] { "Eye" });
|
||||
var guns = Guns(e);
|
||||
var design = DataOf("SpanGun");
|
||||
e.Claim(guns[0], design);
|
||||
e.Claim(guns[1], design);
|
||||
a.RecordReveal(e, guns[0], SignatureChannel.LaunchFlash, e.ClaimOf(guns[0]));
|
||||
b.RecordReveal(e, guns[1], SignatureChannel.LaunchFlash, e.ClaimOf(guns[1]));
|
||||
|
||||
var first = Assert.Single(a.SchematicRows(e));
|
||||
var second = Assert.Single(b.SchematicRows(e));
|
||||
Assert.Same(first.Claimed, second.Claimed);
|
||||
Assert.NotEqual(first.Key, second.Key);
|
||||
}
|
||||
|
||||
// ---- One hull class in the book ----
|
||||
|
||||
// The guard runs both ways: a hull class can be claimed only by the hull, so no item's reveal carries a hull class that
|
||||
// disagrees with Contact.Profile. Mutation: the guard checks the hull side only.
|
||||
[Fact]
|
||||
public void OnlyTheHullClaimsAHullClass()
|
||||
{
|
||||
var e = Add(float3(30, 0, 40), new[] { "Gun" });
|
||||
var gun = Guns(e)[0];
|
||||
Assert.Throws<System.ArgumentException>(() => e.Claim(gun, e.HullData));
|
||||
Assert.Same(gun.Data, e.ClaimOf(gun));
|
||||
}
|
||||
|
||||
// ---- Gear the subject no longer carries ----
|
||||
|
||||
// A reveal of gear the subject does not carry follows every fitted weapon, and stale gear is ordered by lot whatever
|
||||
// order the observer saw it in. Mutation: the missing index sorts first (-1), or stale ties follow the book's slot order.
|
||||
[Fact]
|
||||
public void RevealsOfGearNoLongerCarriedFollowFittedGear()
|
||||
{
|
||||
var e = Add(float3(30, 0, 40), new[] { "Gun" });
|
||||
var gone = Add(float3(60, 0, 40), new[] { "Gun", "Gun" });
|
||||
var o = Add(float3(0, 0, 0), new[] { "Eye" });
|
||||
var fitted = Guns(e)[0];
|
||||
var stale = Guns(gone);
|
||||
Assert.True(stale[0].EquippableItem.Lot < stale[1].EquippableItem.Lot);
|
||||
|
||||
o.RecordReveal(e, stale[1], SignatureChannel.LaunchFlash, stale[1].Data);
|
||||
o.RecordReveal(e, stale[0], SignatureChannel.LaunchFlash, stale[0].Data);
|
||||
o.RecordReveal(e, fitted, SignatureChannel.LaunchFlash, fitted.Data);
|
||||
|
||||
Assert.Equal(new[] { fitted, stale[0], stale[1] }, o.RevealedWeapons(e).Select(r => r.Item).ToArray());
|
||||
}
|
||||
}
|
||||
|
|
@ -89,6 +89,14 @@ public sealed partial class ContactRevealTests : IDisposable
|
|||
Fuse = WeaponFuse.Proximity, BlastRadius = 30f,
|
||||
Behaviors = { Armed(new ConstantWeaponData(), 0) }
|
||||
});
|
||||
// Weapons authored so the rated figure is a function of every stat: a falling damage curve, a Range span, a
|
||||
// MinRange above 0, burst and charge spans (a stat with no terms resolves to its Max live).
|
||||
cache.Upsert(SpannedWeapon("SpanGun", Spanned(new InstantWeaponData { BurstTime = Const(0) })));
|
||||
cache.Upsert(SpannedWeapon("SpanCharged", Spanned(new ChargedWeaponData
|
||||
{
|
||||
BurstTime = Const(0), ChargeTime = Span(1, 3), ChargeEnergy = Const(1), ChargeHeat = Const(0), ChargeFiringDamageMultiplier = 2f
|
||||
})));
|
||||
cache.Upsert(SpannedWeapon("SpanBeam", Spanned(new ConstantWeaponData())));
|
||||
cache.Upsert(Locker("Locker", interceptFactor: null));
|
||||
cache.Upsert(Locker("Hushed", interceptFactor: 4f));
|
||||
cache.Upsert(new ConsumableItemData
|
||||
|
|
@ -153,6 +161,29 @@ public sealed partial class ContactRevealTests : IDisposable
|
|||
return weapon;
|
||||
}
|
||||
|
||||
private static WeaponData Spanned(WeaponData weapon)
|
||||
{
|
||||
Armed(weapon, 5);
|
||||
weapon.Damage = Span(10, 100);
|
||||
weapon.MinRange = Span(0, 20);
|
||||
weapon.Range = Span(50, 120);
|
||||
weapon.DamageCurve = Falloff();
|
||||
if (weapon is InstantWeaponData instant)
|
||||
{
|
||||
instant.Count = Span(1, 2);
|
||||
instant.Cooldown = Span(8, 2);
|
||||
}
|
||||
return weapon;
|
||||
}
|
||||
|
||||
private static WeaponItemData SpannedWeapon(string name, WeaponData weapon) => new WeaponItemData
|
||||
{
|
||||
Name = name, Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1,
|
||||
MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000,
|
||||
Fuse = WeaponFuse.Proximity, BlastRadius = 30f,
|
||||
Behaviors = { weapon }
|
||||
};
|
||||
|
||||
private static GearData Suppressor(string name, float factor, float heatPerUnit)
|
||||
{
|
||||
var cell = new Shape(1, 1);
|
||||
|
|
|
|||
|
|
@ -478,7 +478,7 @@ public sealed class FireAuthorityTests : IDisposable
|
|||
var aimedItem = e.Target.Equipment.Single(x => x.EquippableItem == aimedGear);
|
||||
|
||||
e.Shooter.EntityInfoGathered[e.Target] = 1f; // fully tracked
|
||||
e.Shooter.RecordReveal(e.Target, aimedItem, SignatureChannel.LaunchFlash);
|
||||
e.Shooter.RecordReveal(e.Target, aimedItem, SignatureChannel.LaunchFlash, aimedItem.Data);
|
||||
Assert.True(e.Shooter.TrySelectTargetItem(aimedItem));
|
||||
|
||||
FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter);
|
||||
|
|
|
|||
|
|
@ -519,7 +519,7 @@ public sealed class FireControlCut12Tests : IDisposable
|
|||
var aim = new int2(2, 2);
|
||||
var e = Build(TestSettings(), SolidShape(6, 6), precision: 1000f, markerCells: new[] { aim });
|
||||
var aimed = e.Markers[0];
|
||||
e.Shooter.RecordReveal(e.Target, aimed, SignatureChannel.LaunchFlash);
|
||||
e.Shooter.RecordReveal(e.Target, aimed, SignatureChannel.LaunchFlash, aimed.Data);
|
||||
Assert.True(e.Shooter.TrySelectTargetItem(aimed));
|
||||
|
||||
// The aimed item is still equipped (Entity.TryUnequip refuses on an active entity, and reveal/aim
|
||||
|
|
@ -546,7 +546,7 @@ public sealed class FireControlCut12Tests : IDisposable
|
|||
public void AimingAtAnItemBiggerThanOneCellCentresOnItsCentroid()
|
||||
{
|
||||
var e = Build(TestSettings(), SolidShape(9, 9), precision: 1f, targetFacing: float2(1, 0), equipBigMarker: true);
|
||||
e.Shooter.RecordReveal(e.Target, e.BigMarker, SignatureChannel.LaunchFlash);
|
||||
e.Shooter.RecordReveal(e.Target, e.BigMarker, SignatureChannel.LaunchFlash, e.BigMarker.Data);
|
||||
Assert.True(e.Shooter.TrySelectTargetItem(e.BigMarker));
|
||||
|
||||
var travelDirection = FireControl.TravelDirection(e.Weapon, e.Shooter, e.Target);
|
||||
|
|
@ -584,7 +584,7 @@ public sealed class FireControlCut12Tests : IDisposable
|
|||
// the bounding-box centre along the diagonal, not along either grid axis -- fully onto the lateral
|
||||
// axis, instead of losing most of it to the axis a purely axis-aligned facing would leave unmeasured.
|
||||
var e = Build(TestSettings(), SolidShape(9, 9), precision: 1f, targetFacing: normalize(float2(1, 1)), equipLMarker: true);
|
||||
e.Shooter.RecordReveal(e.Target, e.LMarker, SignatureChannel.LaunchFlash);
|
||||
e.Shooter.RecordReveal(e.Target, e.LMarker, SignatureChannel.LaunchFlash, e.LMarker.Data);
|
||||
Assert.True(e.Shooter.TrySelectTargetItem(e.LMarker));
|
||||
|
||||
var travelDirection = FireControl.TravelDirection(e.Weapon, e.Shooter, e.Target);
|
||||
|
|
@ -975,7 +975,7 @@ public sealed class FireControlCut12Tests : IDisposable
|
|||
var aim = new int2(1, 1);
|
||||
var e = Build(settings, hullShape, precision, spread: 0, targetFacing: normalize(float2(1f, 1f)),
|
||||
markerCells: new[] { aim });
|
||||
e.Shooter.RecordReveal(e.Target, e.Markers[0], SignatureChannel.LaunchFlash);
|
||||
e.Shooter.RecordReveal(e.Target, e.Markers[0], SignatureChannel.LaunchFlash, e.Markers[0].Data);
|
||||
Assert.True(e.Shooter.TrySelectTargetItem(e.Markers[0]));
|
||||
|
||||
var shotId = FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter);
|
||||
|
|
@ -1158,7 +1158,7 @@ public sealed class FireControlCut12Tests : IDisposable
|
|||
|
||||
var aim = new int2(1, 1);
|
||||
var e = Build(TestSettings(), shape, precision: .3f, targetFacing: normalize(float2(.8f, .6f)), markerCells: new[] { aim });
|
||||
e.Shooter.RecordReveal(e.Target, e.Markers[0], SignatureChannel.LaunchFlash);
|
||||
e.Shooter.RecordReveal(e.Target, e.Markers[0], SignatureChannel.LaunchFlash, e.Markers[0].Data);
|
||||
Assert.True(e.Shooter.TrySelectTargetItem(e.Markers[0]));
|
||||
|
||||
var outcomes = FireMany(e, 1500);
|
||||
|
|
@ -1192,7 +1192,7 @@ public sealed class FireControlCut12Tests : IDisposable
|
|||
{
|
||||
var e = Build(settings, hullShape, precision: .6f, targetFacing: facing, markerCells: new[] { aimCell });
|
||||
var aimed = e.Markers[0];
|
||||
e.Shooter.RecordReveal(e.Target, aimed, SignatureChannel.LaunchFlash);
|
||||
e.Shooter.RecordReveal(e.Target, aimed, SignatureChannel.LaunchFlash, aimed.Data);
|
||||
Assert.True(e.Shooter.TrySelectTargetItem(aimed));
|
||||
Assert.Same(aimed, e.Shooter.ResolvedTargetItem);
|
||||
|
||||
|
|
|
|||
|
|
@ -587,8 +587,8 @@ public sealed class FireControlCut5Tests : IDisposable
|
|||
target.Zone.Admit(observer, piloted: false);
|
||||
observer.SetTarget(target);
|
||||
|
||||
observer.RecordReveal(target, big, SignatureChannel.LaunchFlash);
|
||||
observer.RecordReveal(target, medium, SignatureChannel.LaunchFlash);
|
||||
observer.RecordReveal(target, big, SignatureChannel.LaunchFlash, big.Data);
|
||||
observer.RecordReveal(target, medium, SignatureChannel.LaunchFlash, medium.Data);
|
||||
Assert.True(FireControl.IsRevealed(observer, big));
|
||||
Assert.True(FireControl.IsRevealed(observer, medium));
|
||||
Assert.True(observer.TrySelectTargetItem(big));
|
||||
|
|
|
|||
|
|
@ -169,7 +169,7 @@ public sealed class FireControlCut6dTests : IDisposable
|
|||
if (marker != null)
|
||||
{
|
||||
var markerItem = target.Equipment.Single(x => x.EquippableItem == marker);
|
||||
shooter.RecordReveal(target, markerItem, SignatureChannel.LaunchFlash);
|
||||
shooter.RecordReveal(target, markerItem, SignatureChannel.LaunchFlash, markerItem.Data);
|
||||
Assert.True(shooter.TrySelectTargetItem(markerItem));
|
||||
Assert.Same(markerItem, shooter.ResolvedTargetItem);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -167,7 +167,7 @@ public sealed class FireControlCut7Tests : IDisposable
|
|||
|
||||
// The shooter tracks the target fully and has seen the interior item act; it selects it as the aim point.
|
||||
e.Shooter.EntityInfoGathered[e.Target] = 1f;
|
||||
e.Shooter.RecordReveal(e.Target, aimedItem, SignatureChannel.LaunchFlash);
|
||||
e.Shooter.RecordReveal(e.Target, aimedItem, SignatureChannel.LaunchFlash, aimedItem.Data);
|
||||
Assert.True(e.Shooter.TrySelectTargetItem(aimedItem));
|
||||
Assert.Same(aimedItem, e.Shooter.ResolvedTargetItem);
|
||||
|
||||
|
|
|
|||
|
|
@ -68,6 +68,13 @@ public sealed class FireControlTests : IDisposable
|
|||
MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000,
|
||||
Behaviors = { new InstantWeaponData() }
|
||||
});
|
||||
// A second design, so a target can carry two guns whose true figures differ (claim tests).
|
||||
buildCache.Upsert(new GearData
|
||||
{
|
||||
Name = "WeakGun", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1,
|
||||
MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000,
|
||||
Behaviors = { new InstantWeaponData() }
|
||||
});
|
||||
buildCache.FlushAsync().Wait();
|
||||
}
|
||||
|
||||
|
|
@ -175,9 +182,10 @@ public sealed class FireControlTests : IDisposable
|
|||
}
|
||||
|
||||
// A shooter with one in-range, in-arc gun, a detected target at `targetPosition`, and a Minion driving the shooter.
|
||||
private (Ship shooter, Ship target, Zone zone) BuildCombatEncounter(float3 targetPosition, float projectileSpeed = 0f)
|
||||
private (Ship shooter, Ship target, Zone zone) BuildCombatEncounter(float3 targetPosition, float projectileSpeed = 0f, bool twoTargetGuns = false)
|
||||
{
|
||||
var (items, shooter, weapons) = BuildShipWithHardpoints((ItemRotation.None, 0f));
|
||||
var mounts = twoTargetGuns ? new[] { (ItemRotation.None, 0f), (ItemRotation.None, 0f) } : new[] { (ItemRotation.None, 0f) };
|
||||
var (items, shooter, weapons) = BuildShipWithHardpoints(mounts);
|
||||
var weaponData = (InstantWeaponData) weapons[0].Data.Behaviors.Single(b => b is InstantWeaponData);
|
||||
weaponData.Damage = new PerformanceStat { Min = 10, Max = 10 };
|
||||
weaponData.Cooldown = new PerformanceStat { Min = 1, Max = 1 };
|
||||
|
|
@ -194,6 +202,20 @@ public sealed class FireControlTests : IDisposable
|
|||
// gun so the target is a real, ordinarily-constructed ship rather than a workaround-shaped one.
|
||||
var targetGunRef = items.ItemData.RefOf<ItemData>(items.ItemData.GetByName<GearData>("Gun"));
|
||||
Assert.True(target.TryEquip(new EquippableItem { Data = targetGunRef, Durability = 1, Lot = 1 }, new int2(0, 0)));
|
||||
if (twoTargetGuns)
|
||||
{
|
||||
// Both true guns are finite, authored figures: the strong one rates 10, the weak one 1.
|
||||
weaponData.Count = new PerformanceStat { Min = 1, Max = 1 };
|
||||
var weakRef = items.ItemData.RefOf<ItemData>(items.ItemData.GetByName<GearData>("WeakGun"));
|
||||
var weakData = (InstantWeaponData) items.ItemData.GetByName<GearData>("WeakGun").Behaviors.Single();
|
||||
weakData.Damage = new PerformanceStat { Min = 1, Max = 1 };
|
||||
weakData.Count = new PerformanceStat { Min = 1, Max = 1 };
|
||||
weakData.Cooldown = new PerformanceStat { Min = 1, Max = 1 };
|
||||
weakData.Range = new PerformanceStat { Min = 1000, Max = 1000 };
|
||||
weakData.MinRange = new PerformanceStat { Min = 0, Max = 0 };
|
||||
weakData.DamageCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } };
|
||||
Assert.True(target.TryEquip(new EquippableItem { Data = weakRef, Durability = 1, Lot = 2 }, new int2(1, 0)));
|
||||
}
|
||||
zone.Admit(target, piloted: false);
|
||||
|
||||
shooter.Position = float3.zero;
|
||||
|
|
@ -249,7 +271,7 @@ public sealed class FireControlTests : IDisposable
|
|||
zone.Update(1f);
|
||||
Assert.Null(shooter.TargetItem.Value);
|
||||
|
||||
shooter.RecordReveal(target, targetGun, SignatureChannel.LaunchFlash);
|
||||
shooter.RecordReveal(target, targetGun, SignatureChannel.LaunchFlash, targetGun.Data);
|
||||
zone.Update(1f);
|
||||
|
||||
Assert.Same(targetGun, shooter.TargetItem.Value);
|
||||
|
|
@ -262,6 +284,139 @@ public sealed class FireControlTests : IDisposable
|
|||
Assert.Null(shooter.TargetItem.Value);
|
||||
}
|
||||
|
||||
// A design as an observer might be told it is: an instant weapon with rated figures, held in memory (a claim is data,
|
||||
// not a catalog item). Rated damage per second at any range is damage / cooldown (one round, flat curve).
|
||||
private static GearData ClaimedDesign(string name, float damage, float range = 1000f, BezierCurve curve = null) => new GearData
|
||||
{
|
||||
Name = name,
|
||||
Behaviors =
|
||||
{
|
||||
new InstantWeaponData
|
||||
{
|
||||
Damage = new PerformanceStat { Min = 0, Max = damage }, Count = new PerformanceStat { Min = 1, Max = 1 },
|
||||
Cooldown = new PerformanceStat { Min = 1, Max = 1 }, Range = new PerformanceStat { Min = 0, Max = range },
|
||||
MinRange = new PerformanceStat { Min = 0, Max = 0 },
|
||||
DamageCurve = curve ?? new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Ruling ew-aim: the AI classifies from its book, not the target's gear (aetheria-ew, classify-from-belief). A gun
|
||||
// the target claims is not a weapon is not something Combat picks as a weapon, though the gun is truly one and
|
||||
// the shooter has seen it act; clearing the claim and seeing it act again restores the choice. Mutation: Combat
|
||||
// walks target.Equipment (or reveal.Item's true behaviours) instead of RevealedWeapons.
|
||||
[Fact]
|
||||
public void TheAIChoosesFromWhatItBelieves()
|
||||
{
|
||||
var (shooter, target, zone) = BuildCombatEncounter(float3(0, 0, 10));
|
||||
var targetGun = target.Equipment.Single(x => x.Behaviors.Any(b => b is Weapon));
|
||||
|
||||
target.Claim(targetGun, new GearData { Name = "NotAGun" });
|
||||
shooter.RecordReveal(target, targetGun, SignatureChannel.LaunchFlash, target.ClaimOf(targetGun));
|
||||
zone.Update(1f);
|
||||
zone.Update(1f);
|
||||
Assert.Null(shooter.TargetItem.Value);
|
||||
|
||||
target.Claim(targetGun, null);
|
||||
shooter.RecordReveal(target, targetGun, SignatureChannel.LaunchFlash, target.ClaimOf(targetGun));
|
||||
zone.Update(1f);
|
||||
Assert.Same(targetGun, shooter.TargetItem.Value);
|
||||
}
|
||||
|
||||
// Ruling ew-claim-weapon-measure-ruled (rated-design): the AI ranks revealed weapons by their claimed design's
|
||||
// rated figures, so the truly weaker gun claimed as the stronger design is the one chosen, and the unclaimed pair
|
||||
// picks the truly stronger. Mutation: rank by the true weapon's live figure, or by the carrier's stats.
|
||||
[Fact]
|
||||
public void AWeakerGunClaimedAsAStrongerDesignIsChosen()
|
||||
{
|
||||
var (shooter, target, zone) = BuildCombatEncounter(float3(0, 0, 10), twoTargetGuns: true);
|
||||
var strong = target.Equipment.Single(x => x.Data.Name == "Gun");
|
||||
var weak = target.Equipment.Single(x => x.Data.Name == "WeakGun");
|
||||
|
||||
void See()
|
||||
{
|
||||
shooter.RecordReveal(target, strong, SignatureChannel.LaunchFlash, target.ClaimOf(strong));
|
||||
shooter.RecordReveal(target, weak, SignatureChannel.LaunchFlash, target.ClaimOf(weak));
|
||||
zone.Update(1f);
|
||||
zone.Update(1f);
|
||||
}
|
||||
|
||||
See();
|
||||
Assert.Same(strong, shooter.TargetItem.Value); // unclaimed: the truly stronger gun (rated 10 against 1)
|
||||
|
||||
target.Claim(strong, ClaimedDesign("Pea", 2f));
|
||||
target.Claim(weak, ClaimedDesign("Cannon", 90f));
|
||||
See();
|
||||
Assert.Same(weak, shooter.TargetItem.Value);
|
||||
}
|
||||
|
||||
// The ranking reads the claimed design at the distance to the target: a short-range design that out-damages a flat one
|
||||
// up close is worthless at 10 when its Range is 12, and the weaker flat design is chosen. Mutation: Combat halves
|
||||
// targetDistance (the short design then rates 6.2 at 5 and wins), or the rated read ignores the range.
|
||||
[Fact]
|
||||
public void TheAIRanksClaimsAtTheTargetsDistance()
|
||||
{
|
||||
var (shooter, target, zone) = BuildCombatEncounter(float3(0, 0, 10), twoTargetGuns: true);
|
||||
var strong = target.Equipment.Single(x => x.Data.Name == "Gun");
|
||||
var weak = target.Equipment.Single(x => x.Data.Name == "WeakGun");
|
||||
var falling = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 0, 0, 0) } };
|
||||
|
||||
target.Claim(strong, ClaimedDesign("Dart", 10f, range: 12f, curve: falling)); // ~.7 at 10, ~6.2 at 5
|
||||
target.Claim(weak, ClaimedDesign("Slug", 5f)); // 5 at any range
|
||||
shooter.RecordReveal(target, strong, SignatureChannel.LaunchFlash, target.ClaimOf(strong));
|
||||
shooter.RecordReveal(target, weak, SignatureChannel.LaunchFlash, target.ClaimOf(weak));
|
||||
zone.Update(1f);
|
||||
zone.Update(1f);
|
||||
|
||||
Assert.Same(weak, shooter.TargetItem.Value);
|
||||
}
|
||||
|
||||
// Ruling ew-claim-weapon-measure-ruled: no observer reads a target weapon's live state. Two unclaimed guns whose
|
||||
// rated order (10 against 1) is the reverse of their live order (the strong one worn down to a hundredth) are
|
||||
// ranked by the design, so the aim stays on the rated stronger gun. Mutation: rank an unclaimed reveal by the
|
||||
// true weapon's live RangeDamagePerSecond.
|
||||
[Fact]
|
||||
public void TheAIRanksByTheDesignNotTheTargetsLiveGun()
|
||||
{
|
||||
var (shooter, target, zone) = BuildCombatEncounter(float3(0, 0, 10), twoTargetGuns: true);
|
||||
var strong = target.Equipment.Single(x => x.Data.Name == "Gun");
|
||||
var weak = target.Equipment.Single(x => x.Data.Name == "WeakGun");
|
||||
var strongData = (InstantWeaponData) strong.Data.Behaviors.Single(b => b is InstantWeaponData);
|
||||
target.Resolver.AttachModifier(strong, strongData.Damage, "worn", StatModifierType.Multiplier, .01f);
|
||||
|
||||
shooter.RecordReveal(target, strong, SignatureChannel.LaunchFlash, target.ClaimOf(strong));
|
||||
shooter.RecordReveal(target, weak, SignatureChannel.LaunchFlash, target.ClaimOf(weak));
|
||||
zone.Update(1f);
|
||||
zone.Update(1f);
|
||||
|
||||
var liveStrong = strong.Behaviors.OfType<Weapon>().Single().RangeDamagePerSecond(10f);
|
||||
var liveWeak = weak.Behaviors.OfType<Weapon>().Single().RangeDamagePerSecond(10f);
|
||||
Assert.True(liveStrong < liveWeak); // the fixture: live and rated disagree
|
||||
Assert.Same(strong, shooter.TargetItem.Value);
|
||||
}
|
||||
|
||||
// Ties are explicit: two revealed weapons of equal rated figure are taken in the subject's equipment order, whatever
|
||||
// order the shooter saw them in. Mutation: ties go to the last (>=), or the walk follows the book's slot order.
|
||||
[Fact]
|
||||
public void EqualClaimsAreTakenInTheTargetsEquipmentOrder()
|
||||
{
|
||||
var (shooter, target, zone) = BuildCombatEncounter(float3(0, 0, 10), twoTargetGuns: true);
|
||||
var first = target.Equipment.Single(x => x.Data.Name == "Gun");
|
||||
var second = target.Equipment.Single(x => x.Data.Name == "WeakGun");
|
||||
Assert.True(target.Equipment.IndexOf(first) < target.Equipment.IndexOf(second));
|
||||
var design = ClaimedDesign("Twin", 5f);
|
||||
target.Claim(first, design);
|
||||
target.Claim(second, design);
|
||||
|
||||
shooter.RecordReveal(target, second, SignatureChannel.LaunchFlash, target.ClaimOf(second)); // seen first
|
||||
shooter.RecordReveal(target, first, SignatureChannel.LaunchFlash, target.ClaimOf(first));
|
||||
zone.Update(1f);
|
||||
zone.Update(1f);
|
||||
|
||||
Assert.Equal(new[] { first, second }, shooter.RevealedWeapons(target).Select(r => r.Item).ToArray());
|
||||
Assert.Same(first, shooter.TargetItem.Value);
|
||||
}
|
||||
|
||||
// Controls cut (helm split): Combat aims the shooter at the intercept (Aim) and commands the hull toward it
|
||||
// through the one steering law (Turn); it never turns the hull by writing Aim.
|
||||
[Fact]
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ public sealed partial class FireControlCut124Tests
|
|||
// Fires until a shot lands; returns that outcome and the absorbs the target published for that shot alone.
|
||||
private static (ShotOutcome Outcome, List<AbsorbEvent> Absorbs) FireAtTheMarker(Engagement e, List<IDisposable> subs)
|
||||
{
|
||||
e.Shooter.RecordReveal(e.Target, e.Custom["Marker"], SignatureChannel.LaunchFlash); // the book must hold the item: aiming is earned by seeing it act
|
||||
e.Shooter.RecordReveal(e.Target, e.Custom["Marker"], SignatureChannel.LaunchFlash, e.Custom["Marker"].Data); // the book must hold the item: aiming is earned by seeing it act
|
||||
Assert.True(e.Shooter.TrySelectTargetItem(e.Custom["Marker"]), "fixture: the marker is aimable");
|
||||
var absorbs = Collect(e.Target, subs);
|
||||
ShotOutcome landed = null;
|
||||
|
|
|
|||
376
tests/Aetheria.Shared.Tests/LaunchSuppressorCatalogTests.cs
Normal file
376
tests/Aetheria.Shared.Tests/LaunchSuppressorCatalogTests.cs
Normal file
|
|
@ -0,0 +1,376 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using CultMath;
|
||||
using GameCult.Caching;
|
||||
using Xunit;
|
||||
|
||||
// The two launch suppressors the catalog sells (campaign aetheria-ew, cut ew-hide-launch-catalog; rulings
|
||||
// ew-suppressor-strength-ruled, ew-suppressor-makers-ruled, ew-mild-suppressor-makers-defaulted), read from the shipped catalog and
|
||||
// fitted to shipped hulls: what the designs say, that quality moves only the stat it is authored on, and what a player feels. The
|
||||
// strong Launch Suppressor hides a launch from the target it is fired at and gives out after an opening volley; the mild Launch
|
||||
// Baffle leaves the target its sight, hides the launch from the crowd and lasts a fight. The entity-level mechanism belongs to
|
||||
// ContactRevealTests; this file owns the catalog's figures.
|
||||
public sealed class LaunchSuppressorCatalogTests
|
||||
{
|
||||
internal const string StrongName = "Launch Suppressor";
|
||||
internal const string MildName = "Launch Baffle";
|
||||
// The observer's sensor, named: the reveal readings below are this design's PassiveInfo, so a catalog that gains an earlier
|
||||
// sensor must not change which one the observer fits.
|
||||
private const string SensorName = "not if i see you first";
|
||||
private const float Flash = 1000f;
|
||||
private const float Tick = .25f; // exact in floats: four ticks are one second
|
||||
|
||||
private static GearData Design(CultCache cache, string name) => Assert.Single(cache.GetAll<GearData>(), g => g.Name == name);
|
||||
|
||||
private static LaunchSuppressorData Data(GearData design) => Assert.Single(design.Behaviors.OfType<LaunchSuppressorData>());
|
||||
|
||||
private static void AssertStat(PerformanceStat stat, float min, float max, string role)
|
||||
{
|
||||
Assert.Equal(min, stat.Min, 4);
|
||||
Assert.Equal(max, stat.Max, 4);
|
||||
var term = Assert.Single(stat.Terms);
|
||||
Assert.Equal(StatSource.Quality, term.Source);
|
||||
Assert.Equal(role, term.Role);
|
||||
Assert.Equal(1f, term.Exponent); // linear in the role's quality: the design's range is the whole of what quality changes
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LaunchSuppressorDesignsAreAuthored()
|
||||
{
|
||||
using var cache = RestoredHullsTests.OpenCatalog();
|
||||
var computer = Design(cache, "Targeting Computer");
|
||||
var designs = new[]
|
||||
{
|
||||
(Design: Design(cache, StrongName), Price: 40000, Factor: (.08f, .03f), Heat: (1.4f, .8f), Rated: (310f, 330f)),
|
||||
(Design: Design(cache, MildName), Price: 25000, Factor: (.4f, .2f), Heat: (.8f, .5f), Rated: (310f, 330f)),
|
||||
};
|
||||
foreach (var (design, price, factor, heat, rated) in designs)
|
||||
{
|
||||
Assert.Equal(HardpointType.Tool, design.Hardpoint);
|
||||
Assert.Equal((1, 1), (design.Shape.Width, design.Shape.Height));
|
||||
Assert.True(design.Shape.Cells[0, 0], "the one cell is solid");
|
||||
Assert.Equal(60f, design.Mass);
|
||||
Assert.Equal(50f, design.Durability);
|
||||
Assert.Equal(price, design.Price);
|
||||
Assert.False(string.IsNullOrWhiteSpace(design.Description));
|
||||
Assert.Equal(new[] { "baffles", "heat sink" }, design.Roles.Select(r => r.Name).ToArray());
|
||||
// The thermal constants and temperatures are the Targeting Computer's, the catalog's 1x1 tool.
|
||||
Assert.Equal(
|
||||
(computer.SpecificHeat, computer.Conductivity, computer.MinimumTemperature, computer.MaximumTemperature, computer.OptimalTemperature, computer.PlateauWidth, computer.ThermalResilience),
|
||||
(design.SpecificHeat, design.Conductivity, design.MinimumTemperature, design.MaximumTemperature, design.OptimalTemperature, design.PlateauWidth, design.ThermalResilience));
|
||||
var data = Data(design);
|
||||
AssertStat(data.Factor, factor.Item1, factor.Item2, "baffles");
|
||||
AssertStat(data.HeatPerUnit, heat.Item1, heat.Item2, "heat sink");
|
||||
AssertStat(data.RatedTemperature, rated.Item1, rated.Item2, "heat sink");
|
||||
CultRecordRefs.Validate(design);
|
||||
}
|
||||
|
||||
// Exactly these six products and these makers, each authoring both roles at its maker's own mean and deviation.
|
||||
var profiles = new Dictionary<string, (float Mean, float Dev)>
|
||||
{
|
||||
["NiteLife"] = (.55f, .12f), ["Finch"] = (.65f, .20f), ["DME"] = (.55f, .20f), ["R&D"] = (.60f, .15f),
|
||||
["Lightsail"] = (.55f, .10f), ["Alakrita"] = (.60f, .14f),
|
||||
};
|
||||
foreach (var (name, makers) in new[]
|
||||
{
|
||||
(StrongName, new[] { "DME", "Finch", "NiteLife", "R&D" }),
|
||||
(MildName, new[] { "Alakrita", "Lightsail" }),
|
||||
})
|
||||
{
|
||||
var key = cache.RefOf(Design(cache, name)).Key;
|
||||
var products = cache.GetAll<FactionProductData>().Where(p => p.Design.Key.Equals(key)).ToArray();
|
||||
Assert.Equal(makers, products.Select(p => cache.Get(p.Manufacturer).ShortName).OrderBy(m => m, StringComparer.Ordinal).ToArray());
|
||||
Assert.Equal(products.Length, products.Select(p => p.Name).Distinct().Count());
|
||||
Assert.All(products, product =>
|
||||
{
|
||||
var (mean, dev) = profiles[cache.Get(product.Manufacturer).ShortName];
|
||||
Assert.Equal(new[] { "baffles", "heat sink" }, product.Roles.Select(r => r.Role).OrderBy(r => r, StringComparer.Ordinal).ToArray());
|
||||
Assert.All(product.Roles, r =>
|
||||
{
|
||||
Assert.Equal(mean, r.Mean, 3);
|
||||
Assert.Equal(dev, r.StandardDeviation, 3);
|
||||
});
|
||||
Assert.False(string.IsNullOrWhiteSpace(product.Description));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// A shipped hull with its drives, a reactor, a radiator and the named suppressor fitted before the ship goes live (a ship
|
||||
// cannot be equipped once active), minted through the production lot path with a quality for each of the suppressor's roles.
|
||||
// The recipe is RestoredHullsTests.BuildThrustedShip's; the suppressor takes the first free interior space, as generation does.
|
||||
internal sealed class Rig : IDisposable
|
||||
{
|
||||
public readonly CultCache Cache = RestoredHullsTests.OpenCatalog();
|
||||
public readonly Ship Ship;
|
||||
public readonly EquippedItem Suppressor;
|
||||
public readonly LaunchSuppressorData Data;
|
||||
public readonly int2 Cell;
|
||||
|
||||
public Rig(string hullName, string designName, float baffles = .5f, float sink = .5f, string sensorName = null)
|
||||
{
|
||||
var hull = Cache.GetByName<HullData>(hullName);
|
||||
var design = Design(Cache, designName);
|
||||
Data = LaunchSuppressorCatalogTests.Data(design);
|
||||
var settings = RestoredHullsTests.Settings();
|
||||
var ledger = new ProvenanceLedger();
|
||||
var suppressorLot = hull.Hardpoints.Count + 2;
|
||||
for (var i = 1; i <= suppressorLot; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = .5f };
|
||||
ledger.Lots[suppressorLot] = new Lot
|
||||
{
|
||||
Origin = new Attributed(), Quality = .5f,
|
||||
Roles = new List<RoleFill> { new RoleFill { Role = "baffles", Quality = baffles }, new RoleFill { Role = "heat sink", Quality = sink } },
|
||||
};
|
||||
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);
|
||||
|
||||
var lot = 2;
|
||||
var thrusters = Cache.GetAll<GearData>().Where(g => g.Hardpoint == HardpointType.Thruster).ToArray();
|
||||
foreach (var hardpoint in hull.Hardpoints.Where(h => h.Type == HardpointType.Thruster))
|
||||
{
|
||||
var thruster = thrusters.First(hardpoint.IsFilledBy);
|
||||
Assert.True(Ship.TryEquip(new EquippableItem { Data = Cache.RefOf<ItemData>(thruster), Durability = thruster.Durability, Lot = lot++ }, hardpoint.Position));
|
||||
}
|
||||
foreach (var type in new[] { HardpointType.Reactor, HardpointType.Radiator })
|
||||
{
|
||||
var hardpoint = hull.Hardpoints.First(h => h.Type == type);
|
||||
var filler = Cache.GetAll<GearData>().First(hardpoint.IsFilledBy);
|
||||
Assert.True(Ship.TryEquip(new EquippableItem { Data = Cache.RefOf<ItemData>(filler), Durability = filler.Durability, Lot = lot++ }, hardpoint.Position));
|
||||
}
|
||||
if (sensorName != null)
|
||||
{
|
||||
var sensor = Design(Cache, sensorName);
|
||||
var hardpoint = hull.Hardpoints.First(h => h.Type == HardpointType.Sensors && h.IsFilledBy(sensor));
|
||||
Assert.True(Ship.TryEquip(new EquippableItem { Data = Cache.RefOf<ItemData>(sensor), Durability = sensor.Durability, Lot = lot++ }, hardpoint.Position));
|
||||
}
|
||||
Assert.True(Ship.TryEquip(new EquippableItem { Data = Cache.RefOf<ItemData>(design), Durability = design.Durability, Lot = suppressorLot }));
|
||||
|
||||
zone.Admit(Ship, piloted: false);
|
||||
Ship.Position = float3.zero;
|
||||
zone.Update(0f);
|
||||
Suppressor = Ship.Equipment.Single(e => e.Data.Name == designName);
|
||||
Cell = Suppressor.InsetShape.Coordinates.Single();
|
||||
}
|
||||
|
||||
public void Dispose() => Cache.Dispose();
|
||||
|
||||
// Write one launch flash (a direct call: no update, so nothing cools between calls) and read back what was stored.
|
||||
public float Launch(float strength)
|
||||
{
|
||||
var source = new object();
|
||||
Ship.SetSignature(source, SignatureChannel.LaunchFlash, strength, Suppressor);
|
||||
return Ship.VisibilitySources[source].Strength;
|
||||
}
|
||||
|
||||
public float Reading(PerformanceStat stat) => Suppressor.Evaluate(stat);
|
||||
|
||||
public float Temperature => Ship.Temperature[Cell.x, Cell.y];
|
||||
|
||||
// The temperature the cell reaches after n direct launches of a flash while the suppressor is working: each removed unit
|
||||
// costs HeatPerUnit, spread over the cell's thermal mass. Counts the launches made while the cell is at or under its rating.
|
||||
public int OpeningVolley(float strength)
|
||||
{
|
||||
var factor = Math.Clamp(Reading(Data.Factor), 0f, 1f);
|
||||
var perCall = (strength - strength * factor) * Reading(Data.HeatPerUnit) / Ship.ThermalMass[Cell.x, Cell.y];
|
||||
var rated = Reading(Data.RatedTemperature);
|
||||
var count = 0;
|
||||
for (double t = Temperature; t <= rated; t += perCall) count++;
|
||||
return count;
|
||||
}
|
||||
}
|
||||
|
||||
// One launch of a fight: what was stored, how far the suppressor's cell rose, and the rise the authored figures predict for the
|
||||
// light removed (removed x HeatPerUnit / the cell's thermal mass).
|
||||
internal readonly record struct Launched(float Stored, float Rise, float Predicted);
|
||||
|
||||
// A fight of one launch a second with the hull ticking between them (four quarter-second updates).
|
||||
internal static Launched[] Fight(string hull, string design, float strength, int launches = 60)
|
||||
{
|
||||
using var rig = new Rig(hull, design);
|
||||
var perUnit = rig.Reading(rig.Data.HeatPerUnit);
|
||||
var mass = rig.Ship.ThermalMass[rig.Cell.x, rig.Cell.y];
|
||||
var fight = new Launched[launches];
|
||||
for (var i = 0; i < launches; i++)
|
||||
{
|
||||
var before = rig.Temperature;
|
||||
var stored = rig.Launch(strength);
|
||||
fight[i] = new Launched(stored, rig.Temperature - before, (strength - stored) * perUnit / mass);
|
||||
for (var t = 0; t < 4; t++) rig.Ship.Zone.Update(Tick);
|
||||
}
|
||||
return fight;
|
||||
}
|
||||
|
||||
// The number of launches that are suppressed before the first that is not.
|
||||
internal static int LeadingSuppressed(string hull, string design, float strength, int launches = 60) =>
|
||||
Fight(hull, design, strength, launches).TakeWhile(l => l.Stored < strength).Count();
|
||||
|
||||
// A sensor ship at the origin: the observer. What it reveals is Entity.Sense's own test, a stored strength times its
|
||||
// passive information at the emitter's position over the settings' threshold.
|
||||
private sealed class Observer : IDisposable
|
||||
{
|
||||
private readonly Rig _rig;
|
||||
private readonly float _threshold = RestoredHullsTests.Settings().TargetDetectionInfoThreshold;
|
||||
|
||||
public Observer() => _rig = new Rig("Longinus", MildName, sensorName: SensorName);
|
||||
|
||||
public void Dispose() => _rig.Dispose();
|
||||
|
||||
public bool Sees(float stored, float distance) => stored * _rig.Ship.PassiveInfo(new float2(distance, 0f), 1f) > _threshold;
|
||||
}
|
||||
|
||||
public static IEnumerable<object[]> QualityGrid() =>
|
||||
from design in new[] { StrongName, MildName }
|
||||
from role in new[] { "baffles", "heat sink" }
|
||||
from quality in new[] { 0f, .5f, 1f }
|
||||
select new object[] { design, role, quality };
|
||||
|
||||
// The role under test takes the quality; the other role sits at a quality no row uses, so a stat read from the wrong role
|
||||
// lands somewhere else.
|
||||
[Theory]
|
||||
[MemberData(nameof(QualityGrid))]
|
||||
public void ShippedSuppressorQualityMovesItsStats(string designName, string role, float quality)
|
||||
{
|
||||
const float Decoy = .25f;
|
||||
var baffles = role == "baffles" ? quality : Decoy;
|
||||
var sink = role == "heat sink" ? quality : Decoy;
|
||||
using var rig = new Rig("Longinus", designName, baffles, sink);
|
||||
float Lerp(PerformanceStat stat, float q) => stat.Min + (stat.Max - stat.Min) * q;
|
||||
|
||||
var factor = rig.Reading(rig.Data.Factor);
|
||||
var perUnit = rig.Reading(rig.Data.HeatPerUnit);
|
||||
Assert.Equal(Lerp(rig.Data.Factor, baffles), factor, 4);
|
||||
Assert.Equal(Lerp(rig.Data.HeatPerUnit, sink), perUnit, 4);
|
||||
Assert.Equal(Lerp(rig.Data.RatedTemperature, sink), rig.Reading(rig.Data.RatedTemperature), 4);
|
||||
|
||||
var before = rig.Temperature;
|
||||
var stored = rig.Launch(Flash);
|
||||
Assert.Equal(Flash * factor, stored, 2);
|
||||
var expected = (Flash - stored) * perUnit / rig.Ship.ThermalMass[rig.Cell.x, rig.Cell.y];
|
||||
Assert.InRange(rig.Temperature - before, expected * .999f, expected * 1.001f);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Longinus")]
|
||||
[InlineData("Djinni")]
|
||||
public void StrongSuppressorHidesFromItsTargetAndStopsAfterAnOpeningVolley(string hull)
|
||||
{
|
||||
// From rest with no cooling: each call pays heat into one cell, so a few calls in the rating is crossed and every later
|
||||
// call is bright. The count is derived from the cell's thermal mass and the authored figures; the Djinni's margin is
|
||||
// under a kelvin, so it is never a literal.
|
||||
using var rig = new Rig(hull, StrongName);
|
||||
var expectedSuppressed = rig.OpeningVolley(Flash);
|
||||
Assert.InRange(expectedSuppressed, 2, 8);
|
||||
var stored = Enumerable.Range(0, 12).Select(_ => rig.Launch(Flash)).ToArray();
|
||||
var hidden = rig.Reading(rig.Data.Factor) * Flash;
|
||||
Assert.All(stored.Take(expectedSuppressed), s => Assert.Equal(hidden, s, 2));
|
||||
Assert.All(stored.Skip(expectedSuppressed), s => Assert.Equal(Flash, s));
|
||||
|
||||
// With the hull ticking, one launch a second, a light flash still gets hidden for a dozen launches.
|
||||
Assert.True(LeadingSuppressed(hull, StrongName, 300f) >= 12);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StrongSuppressorHidesALaunchFromTheObserverAtTheTarget()
|
||||
{
|
||||
using var rig = new Rig("Longinus", StrongName);
|
||||
using var observer = new Observer();
|
||||
var hidden = rig.Launch(Flash);
|
||||
Assert.False(observer.Sees(hidden, 1500f)); // the shot leaves unseen
|
||||
Assert.True(observer.Sees(Flash, 1500f)); // the same shot with no suppressor is seen
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MildSuppressorLeavesTheTargetItsSightAndHidesFromTheCrowd()
|
||||
{
|
||||
using var mild = new Rig("Longinus", MildName);
|
||||
using var strong = new Rig("Longinus", StrongName);
|
||||
using var observer = new Observer();
|
||||
var dimmed = mild.Launch(Flash);
|
||||
Assert.Equal(300f, dimmed, 1); // mid quality: Factor .3
|
||||
Assert.True(observer.Sees(dimmed, 1500f)); // the target still sees the launch
|
||||
Assert.False(observer.Sees(dimmed, 6000f)); // a far observer loses it...
|
||||
Assert.True(observer.Sees(Flash, 6000f)); // ...and would have seen it unsuppressed
|
||||
Assert.False(observer.Sees(strong.Launch(Flash), 1500f)); // the strong design hides it from the target too
|
||||
}
|
||||
|
||||
// What the ruled figures make of a flash at mid quality: Factor lerp(.08, .03, .5) and lerp(.4, .2, .5).
|
||||
private static float MidFactor(string design) => design == StrongName ? .055f : .3f;
|
||||
|
||||
// The suppressor's one cell starts at rest (280 K) and a direct launch is made at each of these temperatures around the
|
||||
// rating, a fraction of a kelvin either side: the rating alone decides whether the flash is hidden. No launch step is
|
||||
// involved, so the line is pinned to the kelvin, not to however far a launch happens to move the cell.
|
||||
[Theory]
|
||||
[InlineData(StrongName, 0f, -.5f, true)]
|
||||
[InlineData(StrongName, 0f, 0f, true)]
|
||||
[InlineData(StrongName, 0f, .5f, false)]
|
||||
[InlineData(StrongName, 1f, -.5f, true)]
|
||||
[InlineData(StrongName, 1f, .5f, false)]
|
||||
[InlineData(MildName, 0f, -.5f, true)]
|
||||
[InlineData(MildName, 0f, .5f, false)]
|
||||
[InlineData(MildName, .5f, 0f, true)]
|
||||
[InlineData(MildName, 1f, -.5f, true)]
|
||||
[InlineData(MildName, 1f, .5f, false)]
|
||||
public void TheRatedTemperatureIsTheLineBetweenHidingAndNot(string design, float sink, float offset, bool hidden)
|
||||
{
|
||||
using var rig = new Rig("Longinus", design, sink: sink);
|
||||
var rated = rig.Reading(rig.Data.RatedTemperature);
|
||||
Assert.Equal(310f + 20f * sink, rated, 3); // the authored 310..330 on the heat sink
|
||||
rig.Ship.Temperature[rig.Cell.x, rig.Cell.y] = rated + offset;
|
||||
Assert.Equal(hidden ? Flash * MidFactor(design) : Flash, rig.Launch(Flash), 2);
|
||||
}
|
||||
|
||||
// Hiding is linear in the flash at every strength, and so is the heat it costs: a rule that treats weak flashes differently
|
||||
// (or scales heat with strength) moves a row. Both designs at mid quality, a first launch from rest.
|
||||
public static IEnumerable<object[]> FlashGrid() =>
|
||||
from design in new[] { StrongName, MildName }
|
||||
from strength in new[] { 40f, 200f, 499f, 501f, 1000f, 2400f }
|
||||
select new object[] { design, strength };
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(FlashGrid))]
|
||||
public void SuppressionAndItsHeatAreLinearInTheFlash(string design, float strength)
|
||||
{
|
||||
using var rig = new Rig("Longinus", design);
|
||||
var before = rig.Temperature;
|
||||
var stored = rig.Launch(strength);
|
||||
Assert.Equal(strength * MidFactor(design), stored, 2);
|
||||
var expected = (strength - stored) * rig.Reading(rig.Data.HeatPerUnit) / rig.Ship.ThermalMass[rig.Cell.x, rig.Cell.y];
|
||||
Assert.InRange(rig.Temperature - before, expected * .999f, expected * 1.001f);
|
||||
}
|
||||
|
||||
// "A long fight" for the mild design is launches at one launch a second: 30 of a 300 flash (a light weapon) or 8 of a 1000
|
||||
// flash (a heavy launch), on either hull (probed 36 and 33, and 10 and 10). Every one of those must store exactly the flash
|
||||
// times the authored Factor and cost exactly the heat its removed light predicts, so a design that stops hiding late, or
|
||||
// whose Factor or heat drifts as the cell warms, fails here. Past that the sink sits on its rating and the design flickers:
|
||||
// each launch is either fully hidden or fully bright, never in between. That flicker is the ruled design at its rating.
|
||||
[Theory]
|
||||
[InlineData("Longinus", 300f, 30)]
|
||||
[InlineData("Djinni", 300f, 30)]
|
||||
[InlineData("Longinus", Flash, 8)]
|
||||
[InlineData("Djinni", Flash, 8)]
|
||||
public void MildSuppressorKeepsHidingThroughALongFight(string hull, float flash, int hold)
|
||||
{
|
||||
var fight = Fight(hull, MildName, flash);
|
||||
var hidden = flash * MidFactor(MildName);
|
||||
for (var i = 0; i < hold; i++)
|
||||
{
|
||||
Assert.True(Math.Abs(fight[i].Stored - hidden) < .01f, $"{hull}: launch {i} of {flash} stored {fight[i].Stored}, not the authored {hidden}");
|
||||
Assert.InRange(fight[i].Rise, fight[i].Predicted * .999f, fight[i].Predicted * 1.001f);
|
||||
}
|
||||
Assert.All(fight.Skip(hold), l => Assert.True(Math.Abs(l.Stored - hidden) < .01f || l.Stored == flash, $"{hull}: a launch stored {l.Stored}, neither hidden nor bright"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Longinus")]
|
||||
[InlineData("Djinni")]
|
||||
public void MildSuppressorSurvivesALongerFightThanStrong(string hull)
|
||||
{
|
||||
foreach (var flash in new[] { 300f, Flash })
|
||||
{
|
||||
var mild = LeadingSuppressed(hull, MildName, flash);
|
||||
var strong = LeadingSuppressed(hull, StrongName, flash);
|
||||
Assert.True(mild > strong, $"{hull}: mild {mild} did not outlast strong {strong} at {flash}");
|
||||
}
|
||||
}
|
||||
}
|
||||
171
tests/Aetheria.Shared.Tests/LaunchSuppressorCommandTests.cs
Normal file
171
tests/Aetheria.Shared.Tests/LaunchSuppressorCommandTests.cs
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using GameCult.Caching;
|
||||
using GameCult.Caching.MessagePack;
|
||||
using MessagePack;
|
||||
using Xunit;
|
||||
|
||||
// AetherDb's launch-suppressor command (aetheria-ew, cut ew-hide-launch-catalog), run on a scratch copy of the shipped catalog with
|
||||
// the two designs and their six products removed from it. Apply must write what the shipped catalog holds, and a catalog that is
|
||||
// neither untouched nor fully authored is refused rather than completed.
|
||||
public sealed class LaunchSuppressorCommandTests : IDisposable
|
||||
{
|
||||
private static readonly string[] DesignNames = { LaunchSuppressorCatalogTests.StrongName, LaunchSuppressorCatalogTests.MildName };
|
||||
|
||||
private readonly string _root = Path.Combine(Path.GetTempPath(), "launch-suppressor-command-" + Guid.NewGuid().ToString("N"));
|
||||
private string CatalogPath => Path.Combine(_root, "GameData", "Aetheria.cc");
|
||||
|
||||
// The designs and products as the shipped catalog holds them, taken before they are removed from the scratch copy.
|
||||
private readonly (string Name, byte[] Bytes)[] _shippedDesigns;
|
||||
private readonly string[] _shippedProducts;
|
||||
|
||||
private static byte[] Pack(GearData design) =>
|
||||
MessagePackSerializer.Serialize(design, CultDocumentMessagePackSerialization.OptionsFor(typeof(GearData).Assembly));
|
||||
|
||||
private static string Describe(CultCache cache, FactionProductData product) =>
|
||||
$"{cache.Get(product.Design).Name} | {product.Name} | {cache.Get(product.Manufacturer).ShortName} | {product.Description} | " +
|
||||
string.Join(",", product.Roles.OrderBy(r => r.Role, StringComparer.Ordinal).Select(r => $"{r.Role}:{r.Mean}:{r.StandardDeviation}"));
|
||||
|
||||
public LaunchSuppressorCommandTests()
|
||||
{
|
||||
Directory.CreateDirectory(Path.Combine(_root, "GameData"));
|
||||
Directory.CreateDirectory(Path.Combine(_root, "Assets", "Resources"));
|
||||
var shipped = AetherDb.FindRoot();
|
||||
File.Copy(Path.Combine(shipped, "GameData", "Aetheria.cc"), CatalogPath);
|
||||
File.Copy(Path.Combine(shipped, "Assets", "Resources", "Settings.asset"), Path.Combine(_root, "Assets", "Resources", "Settings.asset"));
|
||||
using var cache = new CultCache(TestCatalog.Registry());
|
||||
cache.AddBackingStore(new SingleFileMessagePackBackingStore(CatalogPath), AetheriaStores.CatalogTypes);
|
||||
var designs = cache.GetAll<GearData>().Where(g => DesignNames.Contains(g.Name)).ToArray();
|
||||
Assert.Equal(DesignNames.Length, designs.Length);
|
||||
_shippedDesigns = designs.Select(d => (d.Name, Pack(d))).ToArray();
|
||||
var keys = designs.Select(d => cache.RefOf(d).Key).ToArray();
|
||||
var products = cache.GetAll<FactionProductData>().Where(p => keys.Any(k => p.Design.Key.Equals(k))).ToArray();
|
||||
_shippedProducts = products.Select(p => Describe(cache, p)).OrderBy(x => x, StringComparer.Ordinal).ToArray();
|
||||
Assert.Equal(6, products.Length);
|
||||
foreach (var product in products) Assert.True(cache.Remove(cache.RefOf(product).Key));
|
||||
foreach (var key in keys) Assert.True(cache.Remove(key));
|
||||
cache.FlushAsync().Wait();
|
||||
}
|
||||
|
||||
public void Dispose() => Directory.Delete(_root, true);
|
||||
|
||||
private (GearData[] Designs, string[] Products) Read()
|
||||
{
|
||||
var db = AetherDb.Open(root: _root);
|
||||
try
|
||||
{
|
||||
var designs = db.Cache.GetAll<GearData>().Where(g => DesignNames.Contains(g.Name)).ToArray();
|
||||
var keys = designs.Select(d => db.Cache.RefOf(d).Key).ToArray();
|
||||
var products = db.Cache.GetAll<FactionProductData>().Where(p => keys.Any(k => p.Design.Key.Equals(k))).ToArray();
|
||||
return (designs, products.Select(p => Describe(db.Cache, p)).OrderBy(x => x, StringComparer.Ordinal).ToArray());
|
||||
}
|
||||
finally { db.Cache.Dispose(); }
|
||||
}
|
||||
|
||||
// Remove from the scratch catalog what the predicate picks: products by name, designs by name.
|
||||
private void Remove(Func<FactionProductData, bool> products, Func<GearData, bool> designs)
|
||||
{
|
||||
using var cache = new CultCache(TestCatalog.Registry());
|
||||
cache.AddBackingStore(new SingleFileMessagePackBackingStore(CatalogPath), AetheriaStores.CatalogTypes);
|
||||
foreach (var product in cache.GetAll<FactionProductData>().Where(products).ToArray()) Assert.True(cache.Remove(cache.RefOf(product).Key));
|
||||
foreach (var design in cache.GetAll<GearData>().Where(designs).ToArray()) Assert.True(cache.Remove(cache.RefOf(design).Key));
|
||||
cache.FlushAsync().Wait();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_dry_run_writes_nothing_and_apply_lands_the_shipped_designs_once()
|
||||
{
|
||||
var before = File.ReadAllBytes(CatalogPath);
|
||||
Assert.Empty(Read().Designs);
|
||||
|
||||
Assert.Equal(0, Program.LaunchSuppressorCatalog(apply: false, root: _root));
|
||||
Assert.Equal(before, File.ReadAllBytes(CatalogPath));
|
||||
Assert.Empty(Read().Designs);
|
||||
|
||||
Assert.Equal(0, Program.LaunchSuppressorCatalog(apply: true, root: _root));
|
||||
var landed = File.ReadAllBytes(CatalogPath);
|
||||
Assert.NotEqual(before, landed);
|
||||
var (designs, products) = Read();
|
||||
// The record keys are CultCache's to mint and differ run to run; the designs and products do not.
|
||||
foreach (var (name, bytes) in _shippedDesigns)
|
||||
Assert.True(bytes.AsSpan().SequenceEqual(Pack(designs.Single(d => d.Name == name))), $"{name} differs from the shipped catalog's record");
|
||||
Assert.Equal(_shippedProducts, products);
|
||||
|
||||
// A second apply finds everything authored and writes nothing; so does a dry run.
|
||||
Assert.Equal(0, Program.LaunchSuppressorCatalog(apply: true, root: _root));
|
||||
Assert.Equal(landed, File.ReadAllBytes(CatalogPath));
|
||||
Assert.Equal(0, Program.LaunchSuppressorCatalog(apply: false, root: _root));
|
||||
Assert.Equal(landed, File.ReadAllBytes(CatalogPath));
|
||||
}
|
||||
|
||||
// Re-spell every record's payload in a longer, equally valid MessagePack form (an array16 header for the fixarray one). The
|
||||
// catalog then holds bytes no re-encode through the current types would reproduce, so a write that re-encodes records it did
|
||||
// not author shows as changed bytes; a write that lands onto the file as it is leaves them alone.
|
||||
private int WidenEveryPayload()
|
||||
{
|
||||
var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(CatalogPath));
|
||||
var widened = 0;
|
||||
foreach (var record in snapshot.Records)
|
||||
{
|
||||
var payload = record.Payload;
|
||||
if (payload.Length == 0 || (payload[0] & 0xF0) != 0x90) continue;
|
||||
record.Payload = new byte[] { 0xDC, 0x00, (byte)(payload[0] & 0x0F) }.Concat(payload.Skip(1)).ToArray();
|
||||
widened++;
|
||||
}
|
||||
File.WriteAllBytes(CatalogPath, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot));
|
||||
return widened;
|
||||
}
|
||||
|
||||
private Dictionary<string, CultPersistedRecord> Records() =>
|
||||
CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(CatalogPath)).Records.ToDictionary(r => r.Key, StringComparer.Ordinal);
|
||||
|
||||
[Fact]
|
||||
public void Apply_writes_only_the_records_it_authors()
|
||||
{
|
||||
Assert.True(WidenEveryPayload() > 100, "the scratch catalog should hold many records in the widened spelling");
|
||||
var before = Records();
|
||||
|
||||
Assert.Equal(0, Program.LaunchSuppressorCatalog(apply: true, root: _root));
|
||||
|
||||
var after = Records();
|
||||
Assert.Equal(before.Count + DesignNames.Length + _shippedProducts.Length, after.Count);
|
||||
foreach (var (key, record) in before)
|
||||
{
|
||||
Assert.True(after.TryGetValue(key, out var kept), "a record the command did not author is gone");
|
||||
Assert.Equal(record.SchemaId, kept.SchemaId);
|
||||
Assert.Equal(record.StoredAt, kept.StoredAt);
|
||||
Assert.True(record.Payload.AsSpan().SequenceEqual(kept.Payload), "a record the command did not author has changed bytes");
|
||||
}
|
||||
}
|
||||
|
||||
public enum PartialState { OneDesignOnly, DesignWithSomeProducts, ProductsWithoutTheirDesign }
|
||||
|
||||
[Theory]
|
||||
[InlineData(PartialState.OneDesignOnly)]
|
||||
[InlineData(PartialState.DesignWithSomeProducts)]
|
||||
[InlineData(PartialState.ProductsWithoutTheirDesign)]
|
||||
public void Apply_refuses_a_partly_authored_catalog_and_writes_nothing(PartialState state)
|
||||
{
|
||||
Assert.Equal(0, Program.LaunchSuppressorCatalog(apply: true, root: _root));
|
||||
switch (state)
|
||||
{
|
||||
case PartialState.OneDesignOnly:
|
||||
Remove(p => DesignOf(p) == DesignNames[1], g => g.Name == DesignNames[1]);
|
||||
break;
|
||||
case PartialState.DesignWithSomeProducts:
|
||||
Remove(p => p.Name == "Hush" || p.Name == "Low Profile", g => false);
|
||||
break;
|
||||
case PartialState.ProductsWithoutTheirDesign:
|
||||
Remove(p => false, g => g.Name == DesignNames[0]);
|
||||
break;
|
||||
}
|
||||
var partial = File.ReadAllBytes(CatalogPath);
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => Program.LaunchSuppressorCatalog(apply: true, root: _root));
|
||||
Assert.Equal(partial, File.ReadAllBytes(CatalogPath));
|
||||
}
|
||||
|
||||
private string DesignOf(FactionProductData p) => p.Name is "Low Profile" or "Fast Pass Dim" ? DesignNames[1] : DesignNames[0];
|
||||
}
|
||||
56
tests/Aetheria.Shared.Tests/LockOnScenarioTests.cs
Normal file
56
tests/Aetheria.Shared.Tests/LockOnScenarioTests.cs
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Xunit;
|
||||
using static CultMath.math;
|
||||
|
||||
// The arena the operator flies to check the incoming-lock warning (map: Scenarios for operator checks, SC5 and SC14). The
|
||||
// test launches the scenario the way New Game does and steps what it staged.
|
||||
public sealed partial class RunStartTests
|
||||
{
|
||||
// Lock On: the hostile starts piloted, facing the player, beyond its own sight of the player, and the player has no
|
||||
// incoming paint. Stepped at .1 s, it closes, finds the player and locks: a Track paint reaches the player bearing
|
||||
// toward the hostile, and its Strength rises from nothing as the lock tightens.
|
||||
[Fact]
|
||||
public void LockOnSituation()
|
||||
{
|
||||
var (_, arena, staged, failures) = Launch(new LockOn(), Inputs(() => GalaxySeed));
|
||||
Assert.True(failures.Count == 0, string.Join("; ", failures));
|
||||
var player = staged.Player;
|
||||
var hostile = Assert.Single(staged.Entities);
|
||||
Assert.True(player.IsHostileTo(hostile) && hostile.IsHostileTo(player), "the hostile is hostile both ways");
|
||||
Assert.Equal(1, AgentsOf(arena, hostile));
|
||||
|
||||
var toPlayer = normalize(player.Position.xz - hostile.Position.xz);
|
||||
Assert.True(dot(normalize(hostile.Direction), toPlayer) >= cos(radians(5f)), "the hostile faces the player");
|
||||
|
||||
// The first real sensing tick: the hostile holds no track of the player, and the sim's own passive sensing, asked for
|
||||
// the player's signature at the staged place, settles below the detection threshold: the staging is out of its sight.
|
||||
arena.Update(.1f);
|
||||
Assert.DoesNotContain(player, hostile.VisibleEnemies);
|
||||
Assert.DoesNotContain(player.IncomingPaints, paint => paint.Mode == PaintMode.Track);
|
||||
Assert.True(player.Visibility > 0f, "the player shows a signature to be seen by");
|
||||
var threshold = player.ItemManager.GameplaySettings.TargetDetectionInfoThreshold;
|
||||
var settled = hostile.PassiveInfo(player.Position.xz, player.Visibility);
|
||||
Assert.True(settled <= threshold, $"the hostile would settle at {settled} on the player, above the detection threshold {threshold}: staged inside its sight");
|
||||
|
||||
var lockedAt = -1;
|
||||
var bearingDot = 0f;
|
||||
var strengths = new List<float>();
|
||||
for (var tick = 0; tick < 600 && (lockedAt < 0 || tick < lockedAt + 40); tick++)
|
||||
{
|
||||
arena.Update(.1f);
|
||||
foreach (var paint in player.IncomingPaints.Where(paint => paint.Mode == PaintMode.Track))
|
||||
{
|
||||
if (lockedAt < 0)
|
||||
{
|
||||
lockedAt = tick;
|
||||
bearingDot = dot(normalize(paint.Bearing), toPlayer * -1f);
|
||||
}
|
||||
strengths.Add(paint.Strength);
|
||||
}
|
||||
}
|
||||
Assert.True(lockedAt >= 0, "the hostile never locked the player within 600 ticks of .1 s");
|
||||
Assert.True(bearingDot >= cos(radians(5f)), "the warning points at the hostile");
|
||||
Assert.True(strengths.First() < strengths.Last(), $"the lock tightens: Strength went {strengths.First()} to {strengths.Last()}");
|
||||
}
|
||||
}
|
||||
|
|
@ -107,14 +107,13 @@ public sealed class PickupTests : IDisposable
|
|||
return new Lab { Items = items, Zone = zone };
|
||||
}
|
||||
|
||||
// A ship with a cargo bay, aimed along +x and (unless told otherwise) carrying the tractor beam.
|
||||
private static Ship Spawn(Lab lab, float3 at, bool beam = true)
|
||||
// A ship with `bays` cargo bays (bays are equipped before the zone admits the ship), aimed along +x and
|
||||
// (unless told otherwise) carrying the tractor beam.
|
||||
private static Ship Spawn(Lab lab, float3 at, bool beam = true, int bays = 1)
|
||||
{
|
||||
var hull = lab.Items.ItemData.RefOf<ItemData>(lab.Items.ItemData.GetByName<HullData>("Skiff"));
|
||||
var ship = new Ship(lab.Items, lab.Zone, new EquippableItem { Data = hull, Durability = 1000000, Lot = lab.Lot++ }, new EntitySettings());
|
||||
var crate = lab.Make("Crate");
|
||||
Assert.True(ship.TryFindSpace(crate, out var cratePos));
|
||||
Assert.True(ship.TryEquip(crate, cratePos));
|
||||
for (var i = 0; i < bays; i++) Equip(ship, lab.Make("Crate"));
|
||||
if (beam) Equip(ship, lab.Make("Tractor"));
|
||||
lab.Zone.Admit(ship, piloted: false);
|
||||
ship.Position = at;
|
||||
|
|
@ -404,6 +403,29 @@ public sealed class PickupTests : IDisposable
|
|||
Assert.True(block.Holder == null, "block is held");
|
||||
}
|
||||
|
||||
// Either bay may be the full one: a selection that read only the first bay, or only the last, fails one orientation.
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(1)]
|
||||
public void TheBeamTakesFromWhicheverBayCanStore(int fullBay)
|
||||
{
|
||||
using var bag = new CompositeDisposable();
|
||||
var lab = Build(Beam());
|
||||
var ship = Spawn(lab, float3(0, 0, 0), bays: 2);
|
||||
Assert.Equal(2, ship.CargoBays.Count);
|
||||
var freeBay = 1 - fullBay;
|
||||
for (var i = 0; i < 16; i++) Assert.True(ship.CargoBays[fullBay].TryStore(lab.Make("Spare")));
|
||||
var ore = Drift(lab, 40, 0);
|
||||
var events = Watch(ship, bag);
|
||||
ship.TractorCommand = 1;
|
||||
Tick(lab, 300, 1 / 60f);
|
||||
Assert.Contains(events, e => e.Phase == GrabPhase.Started && e.Target.Equals(ore.Id));
|
||||
Assert.Contains(events, e => e.Phase == GrabPhase.Completed);
|
||||
Assert.False(lab.Zone.FloatingItems.Contains(ore), "ore is still in the zone");
|
||||
Assert.Equal(16, ship.CargoBays[fullBay].Cargo.Count);
|
||||
Assert.Single(ship.CargoBays[freeBay].Cargo);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnItemInTheBeamsFarCornerIsCollected()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -454,7 +454,7 @@ public sealed class SignatureTests : IDisposable
|
|||
var view = r.Ship.VisibilitySources;
|
||||
Assert.False(view is Dictionary<object, Signature>);
|
||||
Assert.True(ReferenceEquals(view, r.Ship.VisibilitySources), "one view object per entity"); // not Assert.Same: its failure message formats the entity graph and aborts the test host
|
||||
Assert.Throws<NotSupportedException>(() => ((IDictionary<object, Signature>) view)[r.Ship] = new Signature(SignatureChannel.Thermal, 1f, null));
|
||||
Assert.Throws<NotSupportedException>(() => ((IDictionary<object, Signature>) view)[r.Ship] = new Signature(SignatureChannel.Thermal, 1f, null, null));
|
||||
}
|
||||
|
||||
// ---- APlumeKeepsTheLargerBurn ----
|
||||
|
|
|
|||
|
|
@ -168,7 +168,7 @@ public sealed class TargetingSystemTests : IDisposable
|
|||
Assert.False(observer.TrySelectTargetItem(weapon)); // nothing seen yet
|
||||
Assert.Null(observer.TargetItem.Value);
|
||||
|
||||
observer.RecordReveal(target, weapon, SignatureChannel.LaunchFlash);
|
||||
observer.RecordReveal(target, weapon, SignatureChannel.LaunchFlash, weapon.Data);
|
||||
Assert.True(observer.TrySelectTargetItem(weapon));
|
||||
Assert.Same(weapon, observer.TargetItem.Value);
|
||||
Assert.Same(weapon, observer.ResolvedTargetItem);
|
||||
|
|
@ -196,7 +196,7 @@ public sealed class TargetingSystemTests : IDisposable
|
|||
var observer = BuildObserver(items, target.Zone);
|
||||
observer.SetTarget(target);
|
||||
|
||||
observer.RecordReveal(target, weapon, SignatureChannel.LaunchFlash);
|
||||
observer.RecordReveal(target, weapon, SignatureChannel.LaunchFlash, weapon.Data);
|
||||
|
||||
Assert.True(FireControl.IsRevealed(observer, weapon));
|
||||
Assert.False(FireControl.IsRevealed(observer, wideItem));
|
||||
|
|
@ -230,8 +230,8 @@ public sealed class TargetingSystemTests : IDisposable
|
|||
var (items, target, weapon, wideItem, _, _) = BuildTarget(TestSettings());
|
||||
var observer = BuildObserver(items, target.Zone);
|
||||
observer.SetTarget(target);
|
||||
observer.RecordReveal(target, weapon, SignatureChannel.LaunchFlash);
|
||||
observer.RecordReveal(target, wideItem, SignatureChannel.LaunchFlash);
|
||||
observer.RecordReveal(target, weapon, SignatureChannel.LaunchFlash, weapon.Data);
|
||||
observer.RecordReveal(target, wideItem, SignatureChannel.LaunchFlash, wideItem.Data);
|
||||
Assert.True(observer.TrySelectTargetItem(weapon));
|
||||
|
||||
weapon.EquippableItem.Durability = 0f;
|
||||
|
|
@ -250,7 +250,7 @@ public sealed class TargetingSystemTests : IDisposable
|
|||
var (items, target, weapon, _, _, _) = BuildTarget(TestSettings());
|
||||
var observer = BuildObserver(items, target.Zone);
|
||||
observer.SetTarget(target);
|
||||
observer.RecordReveal(target, weapon, SignatureChannel.LaunchFlash);
|
||||
observer.RecordReveal(target, weapon, SignatureChannel.LaunchFlash, weapon.Data);
|
||||
Assert.True(FireControl.IsRevealed(observer, weapon));
|
||||
|
||||
target.Deactivate(); // an active ship refuses to unequip
|
||||
|
|
@ -269,7 +269,7 @@ public sealed class TargetingSystemTests : IDisposable
|
|||
var (items, target, weapon, _, _, _) = BuildTarget(TestSettings());
|
||||
var observer = BuildObserver(items, target.Zone);
|
||||
observer.EntityInfoGathered[target] = .5f; // below TargetGearInfoThreshold (.9)
|
||||
observer.RecordReveal(target, weapon, SignatureChannel.LaunchFlash);
|
||||
observer.RecordReveal(target, weapon, SignatureChannel.LaunchFlash, weapon.Data);
|
||||
Assert.True(FireControl.IsRevealed(observer, weapon));
|
||||
|
||||
weapon.EquippableItem.Durability = 0f;
|
||||
|
|
|
|||
|
|
@ -46,13 +46,14 @@ public static class Program
|
|||
case "smoke-rounds": return SmokeRoundsCatalog(args.Contains("apply"));
|
||||
case "roles-backfill": return RolesBackfill(args.Contains("apply"));
|
||||
case "consumables-first-set": return ConsumablesFirstSetCatalog(args.Contains("apply"));
|
||||
case "launch-suppressor": return LaunchSuppressorCatalog(args.Contains("apply"));
|
||||
case "pirates-faction":
|
||||
Console.OutputEncoding = new System.Text.UTF8Encoding(false);
|
||||
return PiratesFaction(args.Contains("apply"));
|
||||
case "verse-grammar": return VerseGrammarCommand.Run(args.Contains("apply"));
|
||||
case "generic-designs": return GenericDesigns(args.Contains("apply"));
|
||||
default:
|
||||
Console.WriteLine("commands: census, factions, station-fit, hardpoint-fit, loadout [seed], save, settings, settings-dump, dangling [clear <Type.Member>]... [apply], shield-migrate [apply], brownout-migrate [apply], roles-migrate [apply], firing-arc-migrate [apply], targeting-catalog [apply], targeting-catalog-6c [apply], targeting-catalog-6d [apply], ship-authoring create|inspect|validate|compose, field-kinds [apply], mine-launcher [apply], smoke-hull [apply], smoke-rounds [apply], roles-backfill [apply], pd-gear [apply], consumables-first-set [apply], pirates-faction [apply], verse-grammar [apply], generic-designs [apply]");
|
||||
Console.WriteLine("commands: census, factions, station-fit, hardpoint-fit, loadout [seed], save, settings, settings-dump, dangling [clear <Type.Member>]... [apply], shield-migrate [apply], brownout-migrate [apply], roles-migrate [apply], firing-arc-migrate [apply], targeting-catalog [apply], targeting-catalog-6c [apply], targeting-catalog-6d [apply], ship-authoring create|inspect|validate|compose, field-kinds [apply], mine-launcher [apply], smoke-hull [apply], smoke-rounds [apply], roles-backfill [apply], pd-gear [apply], consumables-first-set [apply], launch-suppressor [apply], pirates-faction [apply], verse-grammar [apply], generic-designs [apply]");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
|
@ -2480,4 +2481,139 @@ public static class Program
|
|||
return 0;
|
||||
}
|
||||
|
||||
// aetheria-ew cut ew-hide-launch-catalog (rulings ew-suppressor-strength-ruled, ew-suppressor-makers-ruled,
|
||||
// ew-mild-suppressor-makers-defaulted): two launch suppressors on the Tool hardpoint, one helper for both. The strong
|
||||
// Launch Suppressor hides a launch from its own target and gives out after an opening volley; the mild Launch Baffle
|
||||
// only dims the flash for distant observers and runs through a long fight. Every figure is one Quality term, linear in
|
||||
// its role (baffles for the Factor, heat sink for the heat), so quality moves the part and nothing else does. Thermal
|
||||
// constants and temperatures come from the Targeting Computer, the catalog's 1x1 tool, as the consumables do. Dry run
|
||||
// unless passed "apply".
|
||||
public static int LaunchSuppressorCatalog(bool apply, string root = null)
|
||||
{
|
||||
var db = AetherDb.Open(catalogWritable: apply, root: root);
|
||||
var designNames = new[] { "Launch Suppressor", "Launch Baffle" };
|
||||
var template = db.Cache.GetAll<GearData>().Single(g => g.Name == "Targeting Computer");
|
||||
|
||||
Faction Maker(string shortName) =>
|
||||
db.Cache.GetAll<Faction>().FirstOrDefault(f => f.ShortName == shortName) ?? throw new InvalidOperationException($"No faction with short name \"{shortName}\".");
|
||||
PerformanceStat Rolled(float min, float max, string role) => new PerformanceStat
|
||||
{
|
||||
Min = min,
|
||||
Max = max,
|
||||
Terms = new List<StatTerm> { new StatTerm { Source = StatSource.Quality, Exponent = 1f, Role = role } },
|
||||
};
|
||||
// Min applies at quality 0 of the role and Max at quality 1: a falling Factor and falling heat per unit are Min > Max.
|
||||
GearData Design(string name, string description, int price, (float Min, float Max) factor, (float Min, float Max) heatPerUnit, (float Min, float Max) rated)
|
||||
{
|
||||
var design = new GearData
|
||||
{
|
||||
Name = name,
|
||||
Description = description,
|
||||
Hardpoint = HardpointType.Tool,
|
||||
Mass = 60f,
|
||||
Price = price,
|
||||
Durability = 50f,
|
||||
Shape = new Shape(1, 1),
|
||||
SpecificHeat = template.SpecificHeat,
|
||||
Conductivity = template.Conductivity,
|
||||
MinimumTemperature = template.MinimumTemperature,
|
||||
MaximumTemperature = template.MaximumTemperature,
|
||||
OptimalTemperature = template.OptimalTemperature,
|
||||
PlateauWidth = template.PlateauWidth,
|
||||
ThermalResilience = template.ThermalResilience,
|
||||
Roles = new[] { "baffles", "heat sink" }.Select(r => new ItemRole { Name = r }).ToList(),
|
||||
Behaviors = new List<BehaviorData>
|
||||
{
|
||||
new LaunchSuppressorData
|
||||
{
|
||||
Factor = Rolled(factor.Min, factor.Max, "baffles"),
|
||||
HeatPerUnit = Rolled(heatPerUnit.Min, heatPerUnit.Max, "heat sink"),
|
||||
RatedTemperature = Rolled(rated.Min, rated.Max, "heat sink"),
|
||||
},
|
||||
},
|
||||
};
|
||||
design.Shape.Cells[0, 0] = true;
|
||||
return design;
|
||||
}
|
||||
|
||||
var strong = Design(designNames[0], "Baffles and a heat sink remove nearly all of a weapon's launch flash, hiding the shot from its target. The removed light is dissipated as heat in the device's own cell; above its rated temperature it passes the full flash.",
|
||||
40000, (.08f, .03f), (1.4f, .8f), (310f, 330f));
|
||||
var mild = Design(designNames[1], "Baffles dim a weapon's launch flash to a fraction and a heat sink absorbs the difference. The target still sees the shot; distant observers do not. The sink sustains the effect through a long fight.",
|
||||
25000, (.4f, .2f), (.8f, .5f), (310f, 330f));
|
||||
var designs = new[] { strong, mild };
|
||||
|
||||
var products = new (GearData Design, string Name, string Maker, string Description)[]
|
||||
{
|
||||
(strong, "Velvet Rope", "NiteLife", "Keeps the flash on the inside. Nobody sees who you let out."),
|
||||
(strong, "Hush", "Finch", "Small, quiet, and the first shot is a secret. The second, probably."),
|
||||
(strong, "lights out", "DME", "no flash/ no name/ nobody saw a thing"),
|
||||
(strong, "Plausible Deniability", "R&D", "Hides 100% of launches!* *Launches defined as the ones nobody saw."),
|
||||
(mild, "Low Profile", "Lightsail", "Quiet on the manifest, quiet on the scope. A hauler nobody notices is a hauler that gets paid."),
|
||||
(mild, "Fast Pass Dim", "Alakrita", "Takes the glare off every launch and keeps the schedule. Anyone close enough to mind was already your problem."),
|
||||
};
|
||||
foreach (var product in products) Maker(product.Maker);
|
||||
|
||||
// Authored when both designs exist and every product is present on its own design; untouched when nothing is
|
||||
// present; anything between needs a human, a product name that belongs to an unrelated item included.
|
||||
var existing = designNames.Select(name => db.Cache.GetAll<GearData>().SingleOrDefault(g => g.Name == name)).ToArray();
|
||||
var productNames = products.Select(p => p.Name).ToHashSet();
|
||||
var presentProducts = db.Cache.GetAll<FactionProductData>().Where(p => productNames.Contains(p.Name)).ToArray();
|
||||
if (existing.All(d => d != null) && presentProducts.Length == products.Length
|
||||
&& products.All(p => presentProducts.Any(x => x.Name == p.Name && x.Design.Key.Equals(db.Cache.RefOf(existing[p.Design == strong ? 0 : 1]).Key))))
|
||||
{
|
||||
Console.WriteLine("The launch suppressors are already authored; nothing to do.");
|
||||
return 0;
|
||||
}
|
||||
if (existing.Any(d => d != null) || presentProducts.Length != 0)
|
||||
throw new InvalidOperationException("The launch suppressors are partly authored; the catalog needs a human.");
|
||||
|
||||
Console.WriteLine("New designs:");
|
||||
foreach (var design in designs)
|
||||
{
|
||||
var data = design.Behaviors.OfType<LaunchSuppressorData>().Single();
|
||||
Console.WriteLine($" {design.Name,-18} {design.Shape.Width}x{design.Shape.Height} {design.Hardpoint} mass {design.Mass:0.##} price {design.Price} durability {design.Durability:0.##} roles [{string.Join(", ", design.Roles.Select(r => r.Name))}]");
|
||||
foreach (var (label, stat) in new[] { ("Factor", data.Factor), ("HeatPerUnit", data.HeatPerUnit), ("RatedTemperature", data.RatedTemperature) })
|
||||
Console.WriteLine($" {label} {stat.Min:0.##}-{stat.Max:0.##} ({stat.Terms.Single().Role})");
|
||||
Console.WriteLine($" \"{design.Description}\"");
|
||||
}
|
||||
Console.WriteLine("\nNew products:");
|
||||
foreach (var (design, name, maker, description) in products)
|
||||
{
|
||||
var (mean, dev) = MakerProfiles[maker];
|
||||
Console.WriteLine($" {design.Name} -> \"{name}\" by {maker}, every role mean {mean:0.##} dev {dev:0.##}: {description}");
|
||||
}
|
||||
|
||||
if (!apply)
|
||||
{
|
||||
Console.WriteLine($"\nDry run. Pass \"apply\" to author 2 designs and {products.Length} products.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
foreach (var design in designs) CultRecordRefs.Validate(design);
|
||||
// ExpectUnchanged lands the batch onto the file as it is: every other record keeps the bytes it has, where a plain
|
||||
// commit would re-encode the whole catalog through today's types.
|
||||
if (!db.Cache.Commit(batch =>
|
||||
{
|
||||
batch.ExpectUnchanged();
|
||||
var keys = new Dictionary<GearData, CultRecordKey>();
|
||||
foreach (var design in designs) keys[design] = batch.Upsert(typeof(GearData), design);
|
||||
foreach (var (design, name, maker, description) in products)
|
||||
{
|
||||
var (mean, dev) = MakerProfiles[maker];
|
||||
batch.Upsert(typeof(FactionProductData), new FactionProductData
|
||||
{
|
||||
Name = name,
|
||||
Description = description,
|
||||
Design = new CultRecordRef<CraftedItemData>(keys[design]),
|
||||
Manufacturer = db.Cache.RefOf(Maker(maker)),
|
||||
Roles = design.Roles.Select(r => new ProductRole { Role = r.Name, Mean = mean, StandardDeviation = dev }).ToList(),
|
||||
});
|
||||
}
|
||||
}))
|
||||
throw new InvalidOperationException("The catalog changed while the launch suppressors were being authored; nothing was written. Run it again.");
|
||||
Console.WriteLine($"\nAuthored 2 designs and {products.Length} products in Aetheria.cc");
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue