Merge cut orbital-layering (closing verdicts cut-orbital-layering.s1, cut-orbital-layering.s2)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-16 05:08:26 +02:00
commit 949fa005d6
27 changed files with 291 additions and 64 deletions

View file

@ -146,6 +146,5 @@ MonoBehaviour:
ActiveEmission: 100
ArmedEmission: 1000
RotationSpeed: 1
GridOffset: 0
HitEffect: {fileID: 1121192042590650, guid: e1b08032bec16ba4eb1ae23cbd572664, type: 3}
HitEffectScale: 10

View file

@ -189,7 +189,6 @@ MonoBehaviour:
m_Name:
m_EditorClassIdentifier:
RotationSpeed: 1
GridOffset: 0
LabelFadeDuration: 0.5
LabelPersistDuration: 3
LabelDisplayAngle: 15

View file

@ -162,7 +162,6 @@ MonoBehaviour:
m_Name:
m_EditorClassIdentifier:
RotationSpeed: 1
GridOffset: 0
LabelFadeDuration: 0.5
LabelPersistDuration: 3
LabelDisplayAngle: 15

View file

@ -162,7 +162,6 @@ MonoBehaviour:
m_Name:
m_EditorClassIdentifier:
RotationSpeed: 1
GridOffset: 0
LabelFadeDuration: 0.5
LabelPersistDuration: 3
LabelDisplayAngle: 15

View file

@ -162,7 +162,6 @@ MonoBehaviour:
m_Name:
m_EditorClassIdentifier:
RotationSpeed: 1
GridOffset: 0
LabelFadeDuration: 0.5
LabelPersistDuration: 3
LabelDisplayAngle: 15

View file

@ -162,7 +162,6 @@ MonoBehaviour:
m_Name:
m_EditorClassIdentifier:
RotationSpeed: 1
GridOffset: 0
LabelFadeDuration: 0.5
LabelPersistDuration: 3
LabelDisplayAngle: 15

BIN
Assets/Resources/Settings.asset (Stored with Git LFS)

Binary file not shown.

View file

@ -100,6 +100,7 @@ namespace Aetheria.EditorTools
{
var settings = AssetDatabase.LoadAssetAtPath<GameSettings>("Assets/Resources/Settings.asset")?.GameplaySettings
?? throw new InvalidOperationException("No Settings.asset.");
settings.FloaterLayerHeight = -1.5f; // nonzero, so a presenter that drops the floater layer fails
var wired = SessionState.GetString(Wiring, "").Split('|').Select(entry => entry.Split('=')).ToDictionary(pair => pair[0], pair => pair[1]);
var pickupPrefab = AssetDatabase.LoadAssetAtPath<GameObject>(wired["SimpleCommodityPickup"])?.GetComponent<ItemPickup>();
@ -178,6 +179,8 @@ namespace Aetheria.EditorTools
want = mine.Body.Position + mineVelocity * lead;
Require(Mathf.Abs(seen.x - want.x) < Tolerance && Mathf.Abs(seen.z - want.y) < Tolerance,
$"MineInstance at lead {lead}: drawn x/z {seen.x},{seen.z}, the step plus velocity times lead {want.x},{want.y}.");
Require(Mathf.Abs(seen.y - DrawAhead.FloaterHeight(zone, settings, new float2(want.x, want.y), lead)) < Tolerance,
$"MineInstance y {seen.y} is not on the floater layer.");
// The round's line is a function of the drawn time: it moves RoundSpeed * lead along its direction between leads.
seen = projectile.transform.position;

View file

@ -90,6 +90,8 @@ public static class FloatingItemsDrawnSmoke
{
var settings = AssetDatabase.LoadAssetAtPath<GameSettings>("Assets/Resources/Settings.asset")?.GameplaySettings
?? throw new InvalidOperationException("No Settings.asset.");
// A floater rides its own layer: a nonzero one (never saved back to the asset), so a presenter that drops it fails.
settings.FloaterLayerHeight = -1.5f;
// The wiring: the scene's ZoneRenderer names an ItemPickup prefab in each of the four fields, and none of those
// prefabs keeps a script that no longer exists.
@ -159,7 +161,7 @@ public static class FloatingItemsDrawnSmoke
{
var lead = ActionGameManager.Clock.Lead;
var at = DrawAhead.Position(floating.Body, lead);
var want = new Vector3(at.x, DrawAhead.Height(zone, at, lead), at.y);
var want = new Vector3(at.x, DrawAhead.FloaterHeight(zone, settings, at, lead), at.y);
Require(Vector3.Distance(pickup.transform.position, want) < 1e-3f,
$"{what}: the pickup is at {pickup.transform.position}, the body at {want}.");
}

View file

@ -12,7 +12,6 @@ using Random = UnityEngine.Random;
public class ItemPickup : MonoBehaviour
{
public float RotationSpeed = 1f;
public float GridOffset;
public float LabelFadeDuration = .5f;
public float LabelPersistDuration = 3;
public float LabelDisplayAngle = 15;
@ -27,11 +26,13 @@ public class ItemPickup : MonoBehaviour
private float _displayTime;
private float _timeOffset;
private Zone _zone;
private GameplaySettings _settings;
private FloatingItem _floating;
public void Bind(Zone zone, FloatingItem floating)
public void Bind(Zone zone, FloatingItem floating, GameplaySettings settings)
{
_zone = zone;
_settings = settings;
_floating = floating;
Item = floating.Item;
_timeOffset = Random.value * 100;
@ -42,7 +43,7 @@ public class ItemPickup : MonoBehaviour
{
var lead = ActionGameManager.Clock.Lead;
var p = DrawAhead.Position(_floating.Body, lead);
transform.position = new Vector3(p.x, DrawAhead.Height(_zone, p, lead) + GridOffset, p.y);
transform.position = new Vector3(p.x, DrawAhead.FloaterHeight(_zone, _settings, p, lead), p.y);
}
private void Update()

View file

@ -17,21 +17,22 @@ public class MineInstance : MonoBehaviour
public float ActiveEmission = 100f;
public float ArmedEmission = 1000f;
public float RotationSpeed = 1f;
public float GridOffset;
// The blast's particles; the effect prefab destroys itself when its particle system stops.
public GameObject HitEffect;
public float HitEffectScale = 10f;
private Zone _zone;
private GameplaySettings _settings;
private Mine _mine;
private Material _material;
private float _timeOffset;
private float _pulse;
private float _emission;
public void Bind(Zone zone, Mine mine)
public void Bind(Zone zone, Mine mine, GameplaySettings settings)
{
_zone = zone;
_settings = settings;
_mine = mine;
_material = MeshRenderer.materials[EmissionSubmesh];
_timeOffset = Random.value * 100;
@ -57,7 +58,7 @@ public class MineInstance : MonoBehaviour
{
var lead = ActionGameManager.Clock.Lead;
var p = DrawAhead.Position(_mine.Body, lead);
transform.position = new Vector3(p.x, DrawAhead.Height(_zone, p, lead) + GridOffset, p.y);
transform.position = new Vector3(p.x, DrawAhead.FloaterHeight(_zone, _settings, p, lead), p.y);
}
// The mine left Zone.Mines, which only its blast does: show it where it was drawn last.

View file

@ -167,5 +167,5 @@ public class Pickup : Behavior, IAlwaysUpdatedBehavior
}
private void Publish(GrabPhase phase, FloatingItem item, float duration) =>
Entity.Capabilities.PublishGrab(new GrabEvent(item.Id, phase, float3(item.Body.Position.x, Entity.Position.y, item.Body.Position.y), duration));
Entity.Capabilities.PublishGrab(new GrabEvent(item.Id, phase, float3(item.Body.Position.x, DrawAhead.FloaterHeight(Entity.Zone, Entity.ItemManager.GameplaySettings, item.Body.Position, 0), item.Body.Position.y), duration));
}

View file

@ -7,8 +7,9 @@ using CultMath;
using static CultMath.math;
using float2 = CultMath.float2;
// A hull touching a hull (aetheria-release cut sim-contact; ruling unity-presents-only-r3): the simulation owns
// contact between entities. Each hull is a disc about its centre of mass that bounds every occupied cell, and
// A ship touching a ship (aetheria-release cut sim-contact; ruling unity-presents-only-r3): the simulation owns
// contact between ships. Orbital entities (stations, turrets) sit on the orbital layer above the ship plane and floaters
// below it, so neither touches a ship (ruling aetheria-gravity:orbitals-above-floaters-below). Each hull is a disc about its centre of mass that bounds every occupied cell, and
// two discs that touch while closing along the line between their centres take the normal impulse
// j = -(1 + e)(v_rel . n) / (1/m1 + 1/m2) (ruling ship-bounce-restitution-setting): momentum is conserved, the
// result is frame-independent, and a pair that has separated is not bounced again. e is
@ -35,7 +36,7 @@ public static class Contacts
{
public static float Radius(Entity e) => e.HullData.Reach(e.ItemManager.GameplaySettings.SchematicCellSize);
// For each unordered pair of the zone's entities, in list order: if their discs touch and are closing along
// For each unordered pair of the zone's ships, in list order: if their discs touch and are closing along
// the normal, the impulse moves each one's velocity along the normal only (the tangent components are
// untouched), and the contact is published. Pairs resolve one after another in list order, each from the
// velocities the earlier pairs left, so a body touching two others in one tick depends on that order; the
@ -43,7 +44,7 @@ public static class Contacts
// are not hulls in the zone. No positional push-out.
public static void Step(Zone zone)
{
var entities = zone.Entities.Where(e => e.Active && e.Parent == null).ToArray();
var entities = zone.Entities.OfType<Ship>().Where(e => e.Active && e.Parent == null).ToArray();
var radii = entities.Select(Radius).ToArray();
for (var i = 0; i < entities.Length; i++)
for (var j = i + 1; j < entities.Length; j++)

View file

@ -26,6 +26,9 @@ public static class DrawAhead
// The field's height at a point at the drawn time, its wells where their rails put them at T.
public static float Height(Zone zone, float2 planar, float lead) => zone.GetHeight(planar, Time(zone, lead));
// The drawn height of a floater (loot, mines): the field at the drawn time plus the floater layer.
public static float FloaterHeight(Zone zone, GameplaySettings settings, float2 planar, float lead) => Height(zone, planar, lead) + settings.FloaterLayerHeight;
// A ship in a wormhole animation has its pose scripted by the step, not integrated from Velocity and TurnRate,
// so it is drawn where the step put it: extrapolating a scripted pose draws a streak that resets every step.
// A child is drawn where its parent is drawn, as the step copies the parent's pose onto it.

View file

@ -1974,9 +1974,13 @@ public abstract class Entity
Manoeuvre.Observe(Acceleration, delta, ItemManager.GameplaySettings.SolutionWindow);
}
// Where an unparented entity sits on the field at a planar point: the zone's height there plus the hull's grid offset.
// The step rides at its own time and xz, the presenter at the drawn time and its drawn xz (DrawAhead).
public float RideHeight(float2 planar, double time) => Zone.GetHeight(planar, time) + (ItemManager.GetData(Hull) as HullData).GridOffset;
// The height above the field of the layer this kind of entity rides on (GameplaySettings): ships the ship plane,
// orbital entities the orbital layer above it.
public abstract float LayerHeight { get; }
// Where an unparented entity sits on the field at a planar point: the zone's height there plus the layer this kind of
// entity rides on. The step rides at its own time and xz, the presenter at the drawn time and its drawn xz (DrawAhead).
public float RideHeight(float2 planar, double time) => Zone.GetHeight(planar, time) + LayerHeight;
public float RideHeight(float2 planar) => RideHeight(planar, Zone.ExactTime);

View file

@ -519,8 +519,7 @@ public class HullData : EquippableItemData
[Inspectable, JsonProperty("hullType"), Key(25)]
public HullType HullType;
[Inspectable, JsonProperty("gridOffset"), Key(26)]
public float GridOffset;
// Key(26) retired 2026-10-16 (per-hull GridOffset; the layer is the kind's, GameplaySettings). Do not reuse.
[Inspectable, JsonProperty("armor"), Key(27)]
public float Armor;

View file

@ -23,6 +23,8 @@ public class OrbitalEntity : Entity
OrbitData = orbit;
}
public override float LayerHeight => ItemManager.GameplaySettings.OrbitalLayerHeight;
public override void Update(float delta)
{
if (OrbitData.IsSet())

View file

@ -278,6 +278,13 @@ public class GameplaySettings
// normal is this times the speed at which it closed. 0 is perfectly inelastic, 1 perfectly elastic.
public float ContactRestitution = .5f;
// Heights above the field of the ship plane, the orbital layer (stations, turrets: every OrbitalEntity) and the floater
// layer (loot, mines): orbitals above ships and floaters below them, so no ship ever touches either (ruling
// aetheria-gravity:orbitals-above-floaters-below).
public float ShipLayerHeight = 3f;
public float OrbitalLayerHeight = 15f;
public float FloaterLayerHeight = 0f;
// The floor an AI's own predicted hit probability (FireControl.HitProbability) must clear before it counts
// a firing solution worth taking (Combat.cs, TurretController.cs). Player fire is gated on arc alone
// (Weapon.ArcAllowsFire, Q2) -- this threshold is an AI fire-discipline heuristic, not part of the roll.

View file

@ -16,6 +16,8 @@ using quaternion = CultMath.quaternion;
[MessagePackObject]
public class Ship : Entity
{
public override float LayerHeight => ItemManager.GameplaySettings.ShipLayerHeight;
// [Key("bindings")] public Dictionary<KeyCode,Guid> Bindings = new Dictionary<KeyCode,Guid>();
//[IgnoreMember] public int HullHardpointCount;

View file

@ -278,7 +278,7 @@ public class ZoneRenderer : MonoBehaviour
private void LoadMine(Zone zone, Mine mine)
{
var instance = Instantiate(MinePrefab);
instance.Bind(zone, mine);
instance.Bind(zone, mine, ItemManager.GameplaySettings);
_mines.Add(mine, instance);
}
@ -293,7 +293,7 @@ public class ZoneRenderer : MonoBehaviour
_ => throw new NotImplementedException()
};
var pickup = Instantiate(prefab, ZoneRoot);
pickup.Bind(zone, floating);
pickup.Bind(zone, floating, ItemManager.GameplaySettings);
pickup.ScanLabel.text = ItemManager.Title(floating.Item);
if (floating.Item is CraftedItemInstance craftedItemInstance)
{

View file

@ -14,7 +14,8 @@ using int2 = CultMath.int2;
// Hull contact in the sim (aetheria-release cut sim-contact; rulings unity-presents-only-r3 and
// ship-bounce-restitution-setting): Zone.Update steps Contacts, which applies the restitution impulse along the
// normal of two touching, closing hull discs and publishes the contact. Every test
// normal of two touching, closing ship discs and publishes the contact. Contact is ship on ship: orbital entities
// sit above the ship plane and take no part in it (ruling aetheria-gravity:orbitals-above-floaters-below). Every test
// drives Zone.Update, the path the game takes. SchematicCellSize is 2, so a solid 5x5 hull has a radius of
// 2.5 * sqrt(2) * 2 = 7.07 and two such hulls ten apart touch.
public sealed class ContactTests : IDisposable
@ -70,6 +71,17 @@ public sealed class ContactTests : IDisposable
return ship;
}
// A station or turret: an orbital entity with no orbit, so it stays where it is put.
public OrbitalEntity AddOrbital(string hull, float3 at, float2 velocity)
{
var hullRef = Items.ItemData.RefOf<ItemData>(Items.ItemData.GetByName<HullData>(hull));
var orbital = new OrbitalEntity(Items, Zone, new EquippableItem { Data = hullRef, Durability = 1000000, Lot = Lot++ }, default, new EntitySettings());
Zone.Admit(orbital, piloted: false);
orbital.Position = at;
orbital.Velocity = velocity;
return orbital;
}
public void Step() => Zone.Update(.01f);
}
@ -86,9 +98,9 @@ public sealed class ContactTests : IDisposable
var cache = AetheriaStores.Open(Path.Combine(_root, Guid.NewGuid().ToString("N") + ".cc"), catalogWritable: true);
_caches.Add(cache);
cache.Upsert(new VerseGrammar { Revision = 1 });
HullData Hull(string name, Shape shape, float mass) => new HullData
HullData Hull(string name, Shape shape, float mass, HullType type = HullType.Ship) => new HullData
{
Name = name, HullType = HullType.Ship, Shape = shape, Durability = 1000000, Mass = mass, Armor = 0,
Name = name, HullType = type, Shape = shape, Durability = 1000000, Mass = mass, Armor = 0,
Hardpoints = { new HardpointData { Type = HardpointType.Sensors, Position = new int2(0, 0), Shape = new Shape() } }
};
cache.Upsert(Hull("Solid", Solid(5, 5), 1000));
@ -97,6 +109,9 @@ public sealed class ContactTests : IDisposable
cache.Upsert(Hull("Mid", Solid(5, 5), 3000));
cache.Upsert(Hull("Ell", Ell(), 1000));
cache.Upsert(Hull("Longinus", ShippedLonginus(), 1000));
// The orbital hulls carry their real types, so a filter by runtime kind and one by hull type are told apart.
cache.Upsert(Hull("Station", Solid(5, 5), 1000000, HullType.Station));
cache.Upsert(Hull("Turret", Solid(5, 5), 1000, HullType.Turret));
cache.FlushAsync().Wait();
var ledger = new ProvenanceLedger();
@ -339,21 +354,21 @@ public sealed class ContactTests : IDisposable
Assert.Empty(lab.Events);
}
// A ship against a 1000x heavier body: the heavy body takes (1 + e) m / (m + M) of the ship's closing speed
// A ship against a 1000x heavier ship: the heavy ship takes (1 + e) m / (m + M) of the ship's closing speed
// and the ship rebounds with the rest of its momentum.
[Fact]
public void AShipAgainstAStationRebounds()
public void AShipAgainstAHeavyHullRebounds()
{
var lab = Build(.5f);
var ship = lab.Add("Solid", float3(0, 0, 0), float2(10, 0));
var station = lab.Add("Heavy", float3(10, 0, 0), float2(0, 0));
var heavy = lab.Add("Heavy", float3(10, 0, 0), float2(0, 0));
Assert.Equal(1000f, ship.Mass);
Assert.Equal(1000000f, station.Mass);
Assert.Equal(1000000f, heavy.Mass);
lab.Step();
var n = NormalOf(ship, station);
var n = NormalOf(ship, heavy);
var closing = 10f * n.x;
Assert.Equal(1.5f * closing * 1000f / 1001000f, dot(n, station.Velocity), 4);
Assert.Equal(1.5f * closing * 1000f / 1001000f, dot(n, heavy.Velocity), 4);
Assert.Equal(closing - 1.5f * closing * 1000000f / 1001000f, dot(n, ship.Velocity), 3);
}
@ -371,6 +386,41 @@ public sealed class ContactTests : IDisposable
Assert.Equal(-10f, b.Velocity.x, 4);
}
// Orbital entities live above the ship plane (ruling orbitals-above-floaters-below): a ship closing on a station
// and on a turret flies through both, and nothing is published.
[Fact]
public void AShipFliesThroughAStationAndATurret()
{
var lab = Build();
var ship = lab.Add("Solid", float3(0, 0, 0), float2(10, 0));
var station = lab.AddOrbital("Station", float3(10, 0, 0), float2(0, 0));
// Behind the ship and faster than it, so it closes on the ship as the station does.
var turret = lab.AddOrbital("Turret", float3(-10, 0, 0), float2(30, 0));
lab.Step();
Assert.Empty(lab.Events);
Assert.Equal(float2(10, 0), ship.Velocity);
Assert.Equal(float2(0, 0), station.Velocity);
Assert.Equal(float2(30, 0), turret.Velocity);
}
// An orbital overlapping a colliding pair changes nothing about the pair (ruling ship-bounce-restitution-setting):
// one contact, between the two ships, with the restitution split of the pair alone.
[Fact]
public void TwoShipsStillBounceBesideAnOrbital()
{
var lab = Build(.5f);
var a = lab.Add("Solid", float3(0, 0, 0), float2(10, 0));
var b = lab.Add("Solid", float3(10, 0, 0), float2(-10, 0));
lab.AddOrbital("Station", float3(5, 0, 0), float2(0, 0));
lab.Step();
var contact = Assert.Single(lab.Events);
Assert.Contains(a, new[] { contact.A, contact.B });
Assert.Contains(b, new[] { contact.A, contact.B });
var n = NormalOf(a, b);
Assert.Equal(.5f * 20f * n.x, dot(n, b.Velocity - a.Velocity), 3);
}
// Pairs resolve one after another in list order, so a body touching two others in one tick can leave the pile
// differently when the list is reversed (order independence is not claimed); what the order does not change is
// the momentum of the pile and the two contacts it publishes.

View file

@ -26,7 +26,8 @@ public sealed class DrawnPoseTests : IDisposable
private const float PlanetDistance = 1000f, PlanetPhase = .3f;
private const float MoonDistance = 60f, MoonPhase = .1f;
private const float StationDistance = 3f, StationPhase = .4f;
private const float GridOffset = 2.5f;
private const float ShipLayerHeight = 2.5f;
private const float OrbitalLayerHeight = 7.5f;
// The sun's rail is parentless and off-centre, the shape ZoneGenerator gives a cluster root (FixedPosition = cluster centre).
private static readonly float2 SunCentre = new float2(700f, -300f);
@ -73,7 +74,7 @@ public sealed class DrawnPoseTests : IDisposable
using (var build = AetheriaStores.Open(catalog, catalogWritable: true))
{
build.Upsert(new VerseGrammar { Revision = 1 });
build.Upsert(new HullData { Name = "TestHull", HullType = HullType.Ship, Shape = shape, Durability = 1, Mass = 1000, GridOffset = GridOffset });
build.Upsert(new HullData { Name = "TestHull", HullType = HullType.Ship, Shape = shape, Durability = 1, Mass = 1000 });
build.FlushAsync().Wait();
}
@ -85,7 +86,8 @@ public sealed class DrawnPoseTests : IDisposable
{
DefaultEntitySettings = new EntitySettings(),
Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Rarity = 0, Color = new float3(1, 1, 1) } },
QualityPriceModifier = new ExponentialLerp()
QualityPriceModifier = new ExponentialLerp(),
ShipLayerHeight = ShipLayerHeight, OrbitalLayerHeight = OrbitalLayerHeight
}, _ => { });
CultRecordKey Orbit(CultRecordKey parent, float distance, float phase, float2 fixedPosition = default)
@ -270,9 +272,9 @@ public sealed class DrawnPoseTests : IDisposable
// A ship that stands still 40 u from the planet rides the field of the drawn well, plus its hull's offset.
var ship = new Ship(w.Items, w.Zone, w.Hull, new EntitySettings()) { Position = float3(PlanetAt(w.Zone.ExactTime).x + 40f, 0, PlanetAt(w.Zone.ExactTime).y) };
var drawnShip = DrawAhead.Position(ship, lead);
Assert.Equal(FieldAt(w, drawnShip.xz, time) + GridOffset, drawnShip.y, 3);
Assert.Equal(FieldAt(w, drawnShip.xz, time) + ShipLayerHeight, drawnShip.y, 3);
Assert.Equal(ship.RideHeight(drawnShip.xz, time), drawnShip.y, 4);
Assert.Equal(w.Zone.GetHeight(ship.Position.xz) + GridOffset, ship.RideHeight(ship.Position.xz), 4);
Assert.Equal(w.Zone.GetHeight(ship.Position.xz) + ShipLayerHeight, ship.RideHeight(ship.Position.xz), 4);
}
[Fact]
@ -292,7 +294,7 @@ public sealed class DrawnPoseTests : IDisposable
AssertNear(StationAt(w.Zone.ExactTime + lead), drawn.xz, 2e-3f, "the station on its rail at the drawn time");
AssertNear(DrawAhead.Orbit(w.Zone, w.StationOrbit, lead), drawn.xz, 1e-6f, "the same point DrawAhead.Orbit gives");
Assert.True(length(station.Position.xz + station.Velocity * lead - drawn.xz) > .01f, "a tight rail curves away from its straight extrapolation");
Assert.Equal(w.Zone.GetHeight(drawn.xz, w.Zone.ExactTime + lead) + GridOffset, drawn.y, 4);
Assert.Equal(w.Zone.GetHeight(drawn.xz, w.Zone.ExactTime + lead) + OrbitalLayerHeight, drawn.y, 4);
}
// The generators make orbital entities with no orbit set (Loadout, LoadoutGenerator): a rail-less one is an ordinary

View file

@ -35,8 +35,9 @@ public sealed class FireControlPerWeaponTests : IDisposable
Directory.Delete(_root, true);
}
private static GameplaySettings TestSettings(float minHit) => new GameplaySettings
private static GameplaySettings TestSettings(float minHit, float orbitalLayerHeight) => new GameplaySettings
{
OrbitalLayerHeight = orbitalLayerHeight,
DefaultEntitySettings = new EntitySettings(),
Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Rarity = 0, Color = new float3(1, 1, 1) } },
QualityPriceModifier = new ExponentialLerp(),
@ -71,7 +72,7 @@ public sealed class FireControlPerWeaponTests : IDisposable
{
public Zone Zone;
public Ship Shooter;
public Ship Target;
public Entity Target;
public List<Gun> Guns = new List<Gun>();
public Gun Gun => Guns[0];
public Gun Decoy;
@ -84,12 +85,12 @@ public sealed class FireControlPerWeaponTests : IDisposable
(ItemRotation Rotation, float Arc)[] mounts = null, bool beam = false, WeaponFuse? fuse = null, float blast = 0f,
float range = 100f, float targetRange = 60f, float accuracy = 1f, float minHit = .2f, bool lockWeapon = false,
bool turret = false, float energy = 0f, float heat = 0f, float visibility = 0f, int magazine = 0, bool singleAmmoBurst = false, bool decoy = false, bool charged = false, bool auto = false,
float effective = float.PositiveInfinity, float damageAtMax = 0f, float velocity = 0f, float targetGridOffset = 0f)
float effective = float.PositiveInfinity, float damageAtMax = 0f, float velocity = 0f, float targetLayerHeight = 0f)
{
mounts ??= new[] { (ItemRotation.None, 0f) };
var hullData = new HullData
{
Name = "Hull", HullType = HullType.Ship, Shape = SolidShape(5, 4), Durability = 1000000, Mass = 1000, Armor = 0, GridOffset = targetGridOffset,
Name = "Hull", HullType = HullType.Ship, Shape = SolidShape(5, 4), Durability = 1000000, Mass = 1000, Armor = 0,
Hardpoints = { new HardpointData { Type = HardpointType.Sensors, Position = new int2(0, 0), Shape = new Shape() } }
};
var shooterHullData = new HullData
@ -192,7 +193,7 @@ public sealed class FireControlPerWeaponTests : IDisposable
var ledger = new ProvenanceLedger();
for (var i = 1; i <= 100; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = 1 };
var items = new ItemManager(cache, ledger, TestSettings(minHit), _ => { });
var items = new ItemManager(cache, ledger, TestSettings(minHit, targetLayerHeight), _ => { });
var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "PerWeapon", Owner = null }, null);
EquippableItem Make(string name, int lot, float durability) => new EquippableItem
@ -235,7 +236,12 @@ public sealed class FireControlPerWeaponTests : IDisposable
Assert.True(shooter.TryEquip(t, upos));
}
var target = new Ship(items, zone, new EquippableItem { Data = hullRef, Durability = 1000000, Lot = lot++ }, new EntitySettings());
// A target layer height rides the target on the orbital layer at that height (the shooter is a ship, on the ship layer),
// so the two sit at different heights.
var targetItem = new EquippableItem { Data = hullRef, Durability = 1000000, Lot = lot++ };
Entity target = targetLayerHeight != 0f
? new OrbitalEntity(items, zone, targetItem, default, new EntitySettings())
: new Ship(items, zone, targetItem, new EntitySettings());
Assert.True(target.TryEquip(Make("Gun", lot++, 1), new int2(0, 0)));
zone.Admit(shooter, piloted: false);
@ -308,13 +314,14 @@ public sealed class FireControlPerWeaponTests : IDisposable
Assert.True(r.Gun.Weapon.Firing, "the turret must fire a fused weapon at a designated target out of arc");
}
// The AI aims at the height a target rides at (Entity.RideHeight: field plus the hull's GridOffset), not at the
// plane. A target hull raised 40 u, a shot fast enough to be predicted: the turret's and Combat's aim point at the
// target's stepped pose. Kills: either aim site spelling its own offset (dropped, added to, or scaled).
// The AI aims at the height a target rides at (Entity.RideHeight: field plus the layer the target's kind rides on),
// not at the plane and not at the shooter's own layer. A target on a layer 40 u up against a shooter on the ship
// layer, a shot fast enough to be predicted: the turret's and Combat's aim point at the target's stepped pose.
// Kills: either aim site spelling its own offset (dropped, added to, scaled, or the shooter's layer).
[Fact]
public void ATurretAimsAtTheHeightATargetRidesAt()
{
var r = Build(turret: true, velocity: 500f, targetGridOffset: 40f);
var r = Build(turret: true, velocity: 500f, targetLayerHeight: 40f);
r.Zone.Update(1f);
AssertAimsAtTarget(r);
@ -323,7 +330,7 @@ public sealed class FireControlPerWeaponTests : IDisposable
[Fact]
public void CombatAimsAtTheHeightATargetRidesAt()
{
var r = Build(velocity: 500f, targetGridOffset: 40f);
var r = Build(velocity: 500f, targetLayerHeight: 40f);
r.Zone.Agents.Add(new Minion(r.Shooter));
r.Zone.Update(1f);
r.Zone.Update(1f);

View file

@ -29,8 +29,9 @@ public sealed class FireControlTests : IDisposable
Directory.Delete(_root, true);
}
private static GameplaySettings TestSettings() => new GameplaySettings
private static GameplaySettings TestSettings(float shipLayerHeight = 3f) => new GameplaySettings
{
ShipLayerHeight = shipLayerHeight,
DefaultEntitySettings = new EntitySettings(),
Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Rarity = 0, Color = new float3(1, 1, 1) } },
QualityPriceModifier = new ExponentialLerp(),
@ -45,13 +46,13 @@ public sealed class FireControlTests : IDisposable
// `well` digs the zone one well at the origin (WellFixture), so heights differ across the zone.
private (ItemManager items, Ship ship, EquippedItem[] weapons) BuildShipWithHardpoints(
bool well, (ItemRotation rotation, float firingArc)[] hardpoints, float gridOffset = 0f)
bool well, (ItemRotation rotation, float firingArc)[] hardpoints, float shipLayerHeight = 0f)
{
var hullShape = new Shape(Math.Max(hardpoints.Length, 3), 3);
foreach (var cell in hullShape.AllCoordinates) hullShape[cell] = true;
// Mass defaults to 0 (ItemData.cs:285), which zeroes ThermalMass (Entity.cs MapEntity) and NaNs
// UpdateTemperature -- author a real mass so the entity's own temperature stays a real number.
var hullData = new HullData { Name = "TestHull", HullType = HullType.Ship, Shape = hullShape, Durability = 1, Mass = 1000, GridOffset = gridOffset };
var hullData = new HullData { Name = "TestHull", HullType = HullType.Ship, Shape = hullShape, Durability = 1, Mass = 1000 };
for (var i = 0; i < hardpoints.Length; i++)
hullData.Hardpoints.Add(new HardpointData
{
@ -87,7 +88,7 @@ public sealed class FireControlTests : IDisposable
ledger.Lots[1] = new Lot { Origin = new Attributed(), Quality = 1 }; // hull
for (var i = 0; i < hardpoints.Length; i++)
ledger.Lots[2 + i] = new Lot { Origin = new Attributed(), Quality = 1 }; // guns
var items = new ItemManager(_openCache, ledger, TestSettings(), _ => { });
var items = new ItemManager(_openCache, ledger, TestSettings(shipLayerHeight), _ => { });
var planets = new PlanetSettings();
var pack = new ZonePack();
if (well) WellFixture.Dig(_openCache, pack, planets, 300f, 100f);
@ -386,7 +387,7 @@ public sealed class FireControlTests : IDisposable
[Fact]
public void ADrawnShipRidesTheFieldAtItsDrawnPosition()
{
var (items, ship, _) = BuildShipWithHardpoints(true, new[] { (ItemRotation.None, 0f) }, gridOffset: 2.5f);
var (items, ship, _) = BuildShipWithHardpoints(true, new[] { (ItemRotation.None, 0f) }, shipLayerHeight: 2.5f);
var zone = ship.Zone;
ship.Position = float3(200, 0, 0);
zone.Update(1f);

View file

@ -354,8 +354,9 @@ public sealed partial class FireControlCut124Tests
{
foreach (var shield in new[] { false, true })
{
var e = Build(TestSettings(), SolidShape(5, 4), equipShield: shield, shieldCapacity: 100000f);
e.HullData.GridOffset = 5f;
var raised = TestSettings();
raised.ShipLayerHeight = 5f;
var e = Build(raised, SolidShape(5, 4), equipShield: shield, shieldCapacity: 100000f);
for (var i = 0; i < 30; i++) e.Zone.Update(.1f);
Assert.Equal(5f, e.Target.Position.y, 1e-4);
var subs = new List<IDisposable>();
@ -434,9 +435,10 @@ public sealed partial class FireControlCut124Tests
[Fact]
public void TheImpactHeightIsTheTargetsHeightAndCarriedKeepsRelativeHeight()
{
// Production y is the zone height plus the hull's GridOffset, so a raised hull is reachable headless.
var e = Build(TestSettings(), SolidShape(21, 3), velocity: 0, damage: 100f, custom: new[] { ("Marker", new Shape(), new int2(2, 1), 1f) });
e.HullData.GridOffset = 5f;
// Production y is the zone height plus the ship layer, so a raised ship plane is reachable headless.
var raised = TestSettings();
raised.ShipLayerHeight = 5f;
var e = Build(raised, SolidShape(21, 3), velocity: 0, damage: 100f, custom: new[] { ("Marker", new Shape(), new int2(2, 1), 1f) });
e.Zone.Update(.01f);
Assert.Equal(5f, e.Target.Position.y, 1e-4);
var subs = new List<IDisposable>();

View file

@ -0,0 +1,129 @@
using System;
using System.IO;
using System.Linq;
using System.Reflection;
using CultMath;
using GameCult.Caching;
using GameCult.Caching.MessagePack;
using Xunit;
using static CultMath.math;
using float2 = CultMath.float2;
using float3 = CultMath.float3;
using int2 = CultMath.int2;
// Layers (aetheria-gravity cut orbital-layering; ruling orbitals-above-floaters-below): GameplaySettings' three
// heights decide where each kind of thing sits above the field. A ship rides the ship plane, every orbital entity
// (station, turret) the orbital layer above it, a floater (loot, mine) the floater layer below. No hull carries a
// height of its own.
public sealed class LayerTests : IDisposable
{
private const float ShipHeight = 2.5f, OrbitalHeight = 9f, FloaterHeight = -1.25f;
private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-layers-" + Guid.NewGuid().ToString("N"));
private readonly CultCache _cache;
public LayerTests()
{
Directory.CreateDirectory(_root);
_cache = AetheriaStores.Open(Path.Combine(_root, "layers.cc"), catalogWritable: true);
}
public void Dispose()
{
_cache.Dispose();
Directory.Delete(_root, true);
}
private static GameplaySettings Settings() => new GameplaySettings
{
DefaultEntitySettings = new EntitySettings(),
Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Rarity = 0, Color = new float3(1, 1, 1) } },
QualityPriceModifier = new ExponentialLerp(),
ShipLayerHeight = ShipHeight, OrbitalLayerHeight = OrbitalHeight, FloaterLayerHeight = FloaterHeight
};
private (ItemManager Items, Zone Zone) Build()
{
// Hull sizes and masses are spread so no kind coincides with a size or a mass: a layer chosen by either fails.
Shape Solid(int side)
{
var shape = new Shape(side, side);
foreach (var cell in shape.AllCoordinates) shape[cell] = true;
return shape;
}
_cache.Upsert(new VerseGrammar { Revision = 1 });
_cache.Upsert(new HullData { Name = "Ship", HullType = HullType.Ship, Shape = Solid(5), Durability = 1, Mass = 1000 });
_cache.Upsert(new HullData { Name = "Station", HullType = HullType.Station, Shape = Solid(3), Durability = 1, Mass = 1000000 });
_cache.Upsert(new HullData { Name = "Turret", HullType = HullType.Turret, Shape = Solid(7), Durability = 1, Mass = 50 });
_cache.FlushAsync().Wait();
var ledger = new ProvenanceLedger();
for (var i = 1; i <= 3; 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 { Radius = 0 }, new GalaxyZone { Name = "Layers", Owner = null }, null);
return (items, zone);
}
private static EquippableItem Hull(ItemManager items, string name, int lot) => new EquippableItem
{
Data = items.ItemData.RefOf<ItemData>(items.ItemData.GetByName<HullData>(name)), Durability = 1, Lot = lot
};
[Fact]
public void EachKindRidesItsLayer()
{
var (items, zone) = Build();
var ship = new Ship(items, zone, Hull(items, "Ship", 1), new EntitySettings());
var station = new OrbitalEntity(items, zone, Hull(items, "Station", 2), default, new EntitySettings());
var turret = new OrbitalEntity(items, zone, Hull(items, "Turret", 3), default, new EntitySettings());
foreach (var e in new Entity[] { ship, station, turret }) zone.Admit(e, piloted: false);
ship.Position = float3(40, 0, 10);
station.Position = float3(-30, 0, 20);
turret.Position = float3(5, 0, -60);
zone.Update(.1f);
Assert.Equal(zone.GetHeight(ship.Position.xz) + ShipHeight, ship.Position.y);
Assert.Equal(zone.GetHeight(station.Position.xz) + OrbitalHeight, station.Position.y);
Assert.Equal(zone.GetHeight(turret.Position.xz) + OrbitalHeight, turret.Position.y);
const float lead = .05f;
foreach (var (e, layer) in new (Entity, float)[] { (ship, ShipHeight), (station, OrbitalHeight), (turret, OrbitalHeight) })
{
var drawn = DrawAhead.Position(e, lead);
Assert.Equal(zone.GetHeight(drawn.xz, DrawAhead.Time(zone, lead)) + layer, drawn.y);
}
}
[Fact]
public void OrbitalsAboveShipsAboveFloaters()
{
var authored = AuthoredSettings.Load(RunStartTests.FindRepoRoot()).Read<GameplaySettings>("GameplaySettings");
Assert.True(authored.OrbitalLayerHeight > authored.ShipLayerHeight);
Assert.True(authored.ShipLayerHeight > authored.FloaterLayerHeight);
}
[Fact]
public void AFloaterSitsOnTheFloaterLayer()
{
var (items, zone) = Build();
var settings = items.GameplaySettings;
foreach (var at in new[] { float2(0, 0), float2(37, -12) })
foreach (var lead in new[] { 0f, .05f })
Assert.Equal(DrawAhead.Height(zone, at, lead) + FloaterHeight, DrawAhead.FloaterHeight(zone, settings, at, lead));
}
[Fact]
public void HullKey26IsRetired()
{
var keys = new System.Collections.Generic.HashSet<int>();
for (var t = typeof(HullData); t != null; t = t.BaseType)
foreach (var m in t.GetMembers(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly))
foreach (var k in m.GetCustomAttributes(typeof(MessagePack.KeyAttribute), false).Cast<MessagePack.KeyAttribute>())
if (k.IntKey.HasValue) keys.Add(k.IntKey.Value);
Assert.NotEmpty(keys);
Assert.DoesNotContain(26, keys);
using var shipped = new CultCache(TestCatalog.Registry());
shipped.AddBackingStore(new SingleFileMessagePackBackingStore(TestCatalog.Repo, true), AetheriaStores.CatalogTypes);
Assert.NotEmpty(shipped.GetAll<HullData>());
}
}

View file

@ -74,7 +74,8 @@ public sealed class PickupTests : IDisposable
TargetDetectionInfoThreshold = .1f, TargetGearInfoThreshold = .8f,
FiringArc = 170, CommitHorizon = .5f, SchematicCellSize = 1f,
UnaidedAccuracy = .05f, UnaidedTracking = 10f, UnaidedPrecision = 2f, AgentMinHitProbability = 0f,
LootDropProbability = 0, LootDropVelocity = 25, FloatingItemLifetime = lifetime
LootDropProbability = 0, LootDropVelocity = 25, FloatingItemLifetime = lifetime,
ShipLayerHeight = 3f, FloaterLayerHeight = -1.5f
};
var cache = AetheriaStores.Open(Path.Combine(_root, Guid.NewGuid().ToString("N") + ".cc"), catalogWritable: true);
_caches.Add(cache);
@ -244,6 +245,22 @@ public sealed class PickupTests : IDisposable
}
}
// The beam ends on the loot where the loot is drawn: the field plus the floater layer, not the grabbing ship's plane
// (ruling orbitals-above-floaters-below).
[Fact]
public void AGrabPointRidesTheFloaterLayerNotTheShips()
{
using var bag = new CompositeDisposable();
var lab = Build(Beam());
var ship = Spawn(lab, float3(0, 0, 0));
var item = Drift(lab, 40, 0);
var events = Watch(ship, bag);
ship.TractorCommand = 1;
for (var tick = 1; tick <= 150; tick++) lab.Zone.Update(1 / 60f);
Assert.NotEmpty(events);
Assert.All(events, e => Assert.Equal(lab.Zone.GetHeight(e.TargetPosition.xz, lab.Zone.ExactTime) - 1.5f, e.TargetPosition.y, 3));
}
[Fact]
public void GrabEventsNameTheFloatingItem()
{