Merge origin/master into eureka/aetheria-release-modifier-stacking

This commit is contained in:
Meta Vens 2026-10-10 06:26:08 +02:00
commit 30c008d7a8
12 changed files with 918 additions and 14 deletions

View file

@ -187,6 +187,8 @@ public interface IPowerConsumer
// superseded draft quoted -- checked directly here rather than trusted from that document.
Union(39, typeof(TargetingSystemData)),
Union(40, typeof(MineLayerData)),
Union(41, typeof(ThrottleLockData)),
Union(42, typeof(VapourDumpData)),
JsonObject(MemberSerialization.OptIn)]
public abstract class BehaviorData
{

View file

@ -160,17 +160,18 @@ public class Sensor : Behavior, IEventBehavior, IPowerConsumer
var diff = entity.Position.xz - Entity.Position.xz;
var dist = length(diff);
var obscuration = Entity.Zone.Obscuration(Entity.Position.xz, entity.Position.xz);
float previous, next;
Entity.EntityInfoGathered.TryGetValue(entity, out previous);
if (!_pingedEntities.Contains(entity) && dist < _pingRadius)
{
_pingedEntities.Add(entity);
next = saturate(previous + Gain(entity.Visibility, Evaluate(_data.Sensitivity), Evaluate(_data.PingBoost), dist, dt, true));
next = saturate(previous + Gain(entity.Visibility, Evaluate(_data.Sensitivity), Evaluate(_data.PingBoost), dist, dt, true, obscuration));
}
else
{
var angle = acos(dot(forward, normalize(diff)));
next = saturate(previous + Gain(entity.Visibility, Evaluate(_data.Sensitivity), _data.SensitivityCurve.Evaluate(angle / PI), dist, dt, false));
next = saturate(previous + Gain(entity.Visibility, Evaluate(_data.Sensitivity), _data.SensitivityCurve.Evaluate(angle / PI), dist, dt, false, obscuration));
}
next *= 1 - ItemManager.GameplaySettings.TargetInfoDecay * dt;
//Context.Log($"{entity.Name} visibility {(int)(previous * 100)}% -> {(int)(next * 100)}%");
@ -182,12 +183,14 @@ public class Sensor : Behavior, IEventBehavior, IPowerConsumer
// Mining Cut 3 (docs/mining-cut-refresh.md): the one detection gain rule -- the info one observation of a
// target adds. A ping adds `visibility × sensitivity × PingBoost × distance` once; a passive tick adds
// `visibility × sensitivity × curve(angle/π) × dt / distance`. `response` is PingBoost for a ping and the
// sensitivity curve at the bearing for a passive tick. The entity loop above and the chunk query
// (Entity.ChunkInfo, through PassiveRate) both call this; nothing else multiplies these terms.
public static float Gain(float visibility, float sensitivity, float response, float distance, float dt, bool ping) =>
ping
// sensitivity curve at the bearing for a passive tick. `obscuration` is the fraction of the signal that
// survives the vapour clouds on the sight line (Zone.Obscuration, 1f with none), scaling both. The entity
// loop above and the chunk query (Entity.ChunkInfo, through PassiveRate) both call this; nothing else
// multiplies these terms.
public static float Gain(float visibility, float sensitivity, float response, float distance, float dt, bool ping, float obscuration) =>
(ping
? visibility * sensitivity * response * distance
: visibility * sensitivity * response * dt / distance;
: visibility * sensitivity * response * dt / distance) * obscuration;
// The passive gain per second this sensor draws from a target of `visibility` at the planar `position`:
// Gain over one second, with the bearing measured from this sensor's own facing exactly as Execute does.
@ -195,7 +198,8 @@ public class Sensor : Behavior, IEventBehavior, IPowerConsumer
{
var diff = position - Entity.Position.xz;
var angle = acos(dot(Direction.xz, normalize(diff)));
return Gain(visibility, Evaluate(_data.Sensitivity), _data.SensitivityCurve.Evaluate(angle / PI), length(diff), 1f, false);
return Gain(visibility, Evaluate(_data.Sensitivity), _data.SensitivityCurve.Evaluate(angle / PI), length(diff), 1f, false,
Entity.Zone.Obscuration(Entity.Position.xz, position));
}
// An upper bound on sensitivity x response over every bearing, so PassiveRate(v, p) <= v x this / distance

View file

@ -0,0 +1,35 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
using MessagePack;
using Newtonsoft.Json;
// Presence marks the lock: Entity.ThrottleLocked is true while an active effect carries one, and Ship.Update
// then reads full forward intent in place of MovementDirection. The behaviour holds no state and does nothing
// itself; turning stays with the pilot.
[Inspectable, MessagePackObject, JsonObject(MemberSerialization.OptIn), RuntimeInspectable]
public class ThrottleLockData : BehaviorData
{
public override Behavior CreateInstance(EquippedItem item)
{
return new ThrottleLock(this, item);
}
public override Behavior CreateInstance(ConsumableItemEffect item)
{
return new ThrottleLock(this, item);
}
}
public class ThrottleLock : Behavior
{
public ThrottleLock(ThrottleLockData data, EquippedItem item) : base(data, item)
{
}
public ThrottleLock(ThrottleLockData data, ConsumableItemEffect item) : base(data, item)
{
}
public override bool Execute(float dt) => true;
}

View file

@ -0,0 +1,61 @@
using MessagePack;
using Newtonsoft.Json;
// A coolant dump that becomes a vapour cloud: the first time it runs it vents one cloud around its venter,
// drifting with the venter's velocity. Radius, Opacity and Lifetime are evaluated through the host, so role
// quality applies as for any other stat. Later runs vent nothing.
[Inspectable, MessagePackObject, JsonObject(MemberSerialization.OptIn), RuntimeInspectable]
public class VapourDumpData : BehaviorData
{
[Inspectable, JsonProperty("radius"), Key(1), RuntimeInspectable]
public PerformanceStat Radius = new PerformanceStat();
// How much of a sight line's signal a fresh cloud removes, in [0, 1].
[Inspectable, JsonProperty("opacity"), Key(2), RuntimeInspectable]
public PerformanceStat Opacity = new PerformanceStat();
// Seconds from venting until the cloud has faded away and is removed.
[Inspectable, JsonProperty("lifetime"), Key(3), RuntimeInspectable]
public PerformanceStat Lifetime = new PerformanceStat();
public override Behavior CreateInstance(EquippedItem item)
{
return new VapourDump(this, item);
}
public override Behavior CreateInstance(ConsumableItemEffect item)
{
return new VapourDump(this, item);
}
}
public class VapourDump : Behavior
{
private VapourDumpData _data;
private bool _vented;
public VapourDump(VapourDumpData data, EquippedItem item) : base(data, item)
{
_data = data;
}
public VapourDump(VapourDumpData data, ConsumableItemEffect item) : base(data, item)
{
_data = data;
}
public override bool Execute(float dt)
{
if (_vented) return true;
_vented = true;
Entity.Zone.Vent(new VapourCloud
{
Body = new KinematicBody { Position = Entity.Position.xz, Velocity = Entity.Velocity },
Radius = Evaluate(_data.Radius),
Opacity = Evaluate(_data.Opacity),
Lifetime = Evaluate(_data.Lifetime),
Venter = Entity
});
return true;
}
}

View file

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

View file

@ -872,6 +872,9 @@ public abstract class Entity
initializable.Initialize();
}
// Derived, never written: true iff an active consumable effect carries a ThrottleLock.
public bool ThrottleLocked => _activeConsumables.Any(e => e.Behaviors.OfType<ThrottleLock>().Any());
public ConsumableItemEffect FindActiveConsumable(ConsumableItemData data)
{
return _activeConsumables.FirstOrDefault(ac => ac.Data == data);

View file

@ -233,27 +233,29 @@ public class Ship : Entity
if (_active && !_exitingWormhole && !_enteringWormhole)
{
RecalculateThrust();
// The lock replaces intent here, once; player and agent intent both pass through this one read.
var move = ThrottleLocked ? float2(0, 1) : MovementDirection;
foreach (var thruster in _allThrusters) thruster.Axis = 0;
var rightThrusterTorqueCompensation = abs(RightStrafeTotalTorque) / RightStrafeTorqueThrusters.Count;
foreach (var thruster in _rightThrusters)
{
var thrust = 0f;
thrust += MovementDirection.x;
thrust += move.x;
if (RightStrafeTorqueThrusters.Contains(thruster))
thrust -= MovementDirection.x * (rightThrusterTorqueCompensation / (abs(thruster.Torque) * thruster.Thrust));
thrust -= move.x * (rightThrusterTorqueCompensation / (abs(thruster.Torque) * thruster.Thrust));
thruster.Axis = thrust;
}
var leftThrusterTorqueCompensation = abs(LeftStrafeTotalTorque) / LeftStrafeTorqueThrusters.Count;
foreach (var thruster in _leftThrusters)
{
var thrust = 0f;
thrust += -MovementDirection.x;
thrust += -move.x;
if (LeftStrafeTorqueThrusters.Contains(thruster))
thrust += MovementDirection.x * (leftThrusterTorqueCompensation / (abs(thruster.Torque) * thruster.Thrust));
thrust += move.x * (leftThrusterTorqueCompensation / (abs(thruster.Torque) * thruster.Thrust));
thruster.Axis = thrust;
}
foreach (var thruster in _forwardThrusters) thruster.Axis += MovementDirection.y;
foreach (var thruster in _reverseThrusters) thruster.Axis += -MovementDirection.y;
foreach (var thruster in _forwardThrusters) thruster.Axis += move.y;
foreach (var thruster in _reverseThrusters) thruster.Axis += -move.y;
foreach (var thruster in _clockwiseThrusters) thruster.Axis += Turn;
foreach (var thruster in _counterClockwiseThrusters) thruster.Axis += -Turn;

View file

@ -0,0 +1,32 @@
using CultMath;
using static CultMath.math;
using float2 = CultMath.float2;
// A vented vapour cloud: a free-floating disc that thins every sensor sight line through it. Not an Entity,
// never priced or rolled, never saved. Zone.Vent is the only adder and Zone's cloud step the only remover;
// Zone.Obscuration is the one reader detection uses.
public sealed class VapourCloud
{
public FloatingBodyId Id;
public KinematicBody Body;
public double VentedAt;
public float Lifetime;
public float Radius;
// The opacity at the moment of venting; it fades linearly to nothing over Lifetime.
public float Opacity;
public Entity Venter;
// A cloud with no lifetime divides by zero; saturate maps the NaN and the infinity to nothing left.
public float OpacityAt(double now) =>
saturate(Opacity) * saturate(1f - (float) (now - VentedAt) / Lifetime);
// Whether the segment a-b reaches the disc: the closest point of the segment to the centre lies within the
// radius. A cloud past either end of the segment touches it only if it is near that end.
public bool Touches(float2 a, float2 b)
{
var ab = b - a;
var length2 = lengthsq(ab);
var t = length2 > 0f ? saturate(dot(Body.Position - a, ab) / length2) : 0f;
return lengthsq(Body.Position - (a + ab * t)) <= Radius * Radius;
}
}

View file

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

View file

@ -20,6 +20,10 @@ public class Zone
// Laid mines, in lay order. Zone owns their existence: Lay is the one adder, the mine step the one remover,
// and every removal is a blast. Runtime state, never saved.
public ReactiveCollection<Mine> Mines = new ReactiveCollection<Mine>();
// Vented vapour clouds, in vent order. Zone owns their existence: Vent is the one adder, StepClouds the one
// remover, and Obscuration the one reader that detection uses. Runtime state, never saved.
public ReactiveCollection<VapourCloud> Clouds = new ReactiveCollection<VapourCloud>();
public Dictionary<CultRecordKey, BodyData> Planets = new Dictionary<CultRecordKey, BodyData>();
public Dictionary<CultRecordKey, Planet> PlanetInstances = new Dictionary<CultRecordKey, Planet>();
@ -225,6 +229,7 @@ public class Zone
foreach (var entity in Entities.ToArray()) entity.Update(deltaTime);
StepMines(deltaTime);
StepClouds(deltaTime);
// Cut 3: after every entity has had its chance to fire this tick, age and resolve the shots that
// firing queued. A shot fired this tick with a flight time shorter than CommitHorizon commits and
@ -238,6 +243,39 @@ public class Zone
Mines.Add(mine);
}
public void Vent(VapourCloud cloud)
{
cloud.Id = NextBodyId();
cloud.VentedAt = _time;
Clouds.Add(cloud);
}
// The fraction of a sight line's signal that survives the clouds between `a` and `b`: the product of
// (1 - opacity now) over every cloud whose disc the segment touches, so 1f when none does. Derived from the
// live clouds at each call and stored nowhere; Sensor.Gain is where it enters detection.
public float Obscuration(float2 a, float2 b)
{
var through = 1f;
for (var i = 0; i < Clouds.Count; i++)
{
var cloud = Clouds[i];
if (cloud.Touches(a, b)) through *= 1f - cloud.OpacityAt(_time);
}
return through;
}
// After StepMines: each cloud drifts as a KinematicBody on the zone's force, as a mine does, and is removed
// once its lifetime has run out. Nothing else removes a cloud.
private void StepClouds(float deltaTime)
{
var settings = _itemManager.GameplaySettings;
foreach (var cloud in Clouds.ToArray())
{
cloud.Body.Step(deltaTime, GetForce(cloud.Body.Position), settings);
if (cloud.VentedAt + cloud.Lifetime <= _time) Clouds.Remove(cloud);
}
}
// After the entity loop and before FireControl.Step, in lay order: each mine drifts as a KinematicBody, is
// triggered by the first eligible hull its armed disc touches, and detonates through FireControl.Detonate at
// its trigger plus fuse delay or at its lifetime, whatever its layer does (ruling mine-owner-leash). The

View file

@ -0,0 +1,274 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using CultMath;
using GameCult.Caching;
using Xunit;
using static CultMath.math;
// A ThrottleLock on an active consumable replaces the pilot's movement intent with full forward, read once at
// the top of Ship.Update's active branch. Turn is untouched. Every observation is a thruster's Axis after a real
// Ship.Update, with the effect started through Entity.ActivateConsumable.
public sealed class ThrottleLockTests : IDisposable
{
private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-throttlelock-" + Guid.NewGuid().ToString("N"));
private string Catalog => Path.Combine(_root, "Aetheria.cc");
public ThrottleLockTests() => Directory.CreateDirectory(_root);
public void 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()
};
private static PerformanceStat Constant(float v) => new PerformanceStat { Min = v, Max = v };
private static EquippableItem Mint(CultCache cache, ItemManager items, ItemData design, ItemRotation rotation = ItemRotation.None) => new EquippableItem
{
Data = cache.RefOf<ItemData>(design),
Durability = design is EquippableItemData equippable ? equippable.Durability : 0,
Rotation = rotation,
Lot = items.Lots.Add(new Lot { Design = cache.RefOf<ItemData>(design), Origin = new Attributed(), Quality = .5f, Roles = new List<RoleFill>() })
};
private sealed class Fixture
{
public CultCache Cache;
public ItemManager Items;
public Ship Ship;
public ConsumableItemData Lock;
public Thruster Forward, Reverse, Clockwise;
public Thruster[] Right, Left; // pairs with opposing torque: a lone off-axis strafe thruster cancels its own torque
public void ActivateLock() => Ship.ActivateConsumable(new ConsumableItem
{
Data = Cache.RefOf<ItemData>(Lock),
Lot = Items.Lots.Add(new Lot { Design = Cache.RefOf<ItemData>(Lock), Origin = new Attributed(), Quality = .5f, Roles = new List<RoleFill>() })
});
}
// One thruster per intent direction, plus two reverse thrusters either side of the centre line, one of
// which has clockwise torque (the one Turn reaches). Thrusters are found by their item's rotation, as Ship
// sorts them.
private Fixture Build(float lockDuration)
{
var cache = AetheriaStores.Open(Catalog, catalogWritable: true);
cache.Upsert(new TestCatalogGlobal { Name = "Temperament" });
var hullShape = new Shape(5, 5);
foreach (var cell in hullShape.AllCoordinates) hullShape[cell] = true;
cache.Upsert(new HullData { Name = "Skiff", HullType = HullType.Ship, Shape = hullShape, Durability = 10, Mass = 1000 });
cache.Upsert(new GearData
{
Name = "Thruster", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 10,
MinimumTemperature = 0, MaximumTemperature = 1000, OptimalTemperature = 280, PlateauWidth = 400,
Behaviors = { new ThrusterData { Thrust = Constant(100), Visibility = Constant(0), Heat = Constant(0), EnergyUsage = Constant(0) } }
});
var lockData = new ConsumableItemData { Name = "Overdrive", Duration = lockDuration, Behaviors = { new ThrottleLockData() } };
cache.Upsert(lockData);
cache.FlushAsync().Wait();
var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { });
var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test Zone", Owner = null }, null);
var ship = new Ship(items, zone, Mint(cache, items, cache.GetByName<HullData>("Skiff")), new EntitySettings());
var gear = cache.GetByName<GearData>("Thruster");
Assert.True(ship.TryEquip(Mint(cache, items, gear, ItemRotation.Reversed), new int2(2, 1)));
Assert.True(ship.TryEquip(Mint(cache, items, gear, ItemRotation.None), new int2(1, 2)));
Assert.True(ship.TryEquip(Mint(cache, items, gear, ItemRotation.None), new int2(3, 2)));
// Strafe pairs sit either side of the centre line across the ship's length, so their torques oppose.
Assert.True(ship.TryEquip(Mint(cache, items, gear, ItemRotation.CounterClockwise), new int2(1, 1)));
Assert.True(ship.TryEquip(Mint(cache, items, gear, ItemRotation.CounterClockwise), new int2(1, 3)));
Assert.True(ship.TryEquip(Mint(cache, items, gear, ItemRotation.Clockwise), new int2(3, 1)));
Assert.True(ship.TryEquip(Mint(cache, items, gear, ItemRotation.Clockwise), new int2(3, 3)));
zone.Entities.Add(ship);
ship.Aim = float3(0, 0, 1);
ship.Activate();
var thrusters = ship.GetBehaviors<Thruster>().ToArray();
return new Fixture
{
Cache = cache, Items = items, Ship = ship, Lock = cache.GetByName<ConsumableItemData>("Overdrive"),
Forward = thrusters.Single(t => t.Item.EquippableItem.Rotation == ItemRotation.Reversed),
Right = thrusters.Where(t => t.Item.EquippableItem.Rotation == ItemRotation.CounterClockwise).ToArray(),
Left = thrusters.Where(t => t.Item.EquippableItem.Rotation == ItemRotation.Clockwise).ToArray(),
Reverse = thrusters.First(t => t.Item.EquippableItem.Rotation == ItemRotation.None && t.Torque <= 0),
Clockwise = thrusters.First(t => t.Item.EquippableItem.Rotation == ItemRotation.None && t.Torque > 0)
};
}
// Spec ALockedThrottleIgnoresIntent. Mutation: read MovementDirection in place of move at any of the six
// sites in Ship.Update and the matching assertion goes red.
[Fact]
public void ALockedThrottleIgnoresIntent()
{
var f = Build(100f);
using var _ = f.Cache;
f.ActivateLock();
Assert.True(f.Ship.ThrottleLocked);
foreach (var intent in new[] { float2(0, -1), float2(1, 0), float2(-1, 0) })
{
f.Ship.MovementDirection = intent;
f.Ship.Turn = 0;
f.Ship.Update(0.01f);
Assert.Equal(1f, f.Forward.Axis);
Assert.Equal(0f, f.Reverse.Axis);
Assert.All(f.Right, t => Assert.Equal(0f, t.Axis));
Assert.All(f.Left, t => Assert.Equal(0f, t.Axis));
}
// Turning stays with the pilot: the clockwise-torque thruster follows Turn while the lock holds.
f.Ship.MovementDirection = float2(0, -1);
f.Ship.Turn = 1;
f.Ship.Update(0.01f);
Assert.Equal(1f, f.Clockwise.Axis);
f.Ship.Turn = 0;
f.Ship.Update(0.01f);
Assert.Equal(0f, f.Clockwise.Axis);
}
// Spec TheLockEndsWithItsEffect. Mutation: make ThrottleLocked a latch (set on activation, never cleared)
// and the post-expiry reverse axis stays 0.
[Fact]
public void TheLockEndsWithItsEffect()
{
var f = Build(1f);
using var _ = f.Cache;
f.ActivateLock();
f.Ship.MovementDirection = float2(0, -1);
f.Ship.Update(0.5f);
Assert.Equal(0f, f.Reverse.Axis);
f.Ship.Update(2f); // runs past the Duration; the effect leaves at the end of this tick
Assert.False(f.Ship.ThrottleLocked);
f.Ship.Update(0.01f);
Assert.Equal(1f, f.Reverse.Axis);
Assert.Equal(0f, f.Forward.Axis);
}
// Spec AnUnlockedShipFollowsIntent. Mutation: replace the conditional by an unconditional float2(0, 1) and
// the reverse and strafe assertions go red.
[Fact]
public void AnUnlockedShipFollowsIntent()
{
var f = Build(1f);
using var _ = f.Cache;
Assert.False(f.Ship.ThrottleLocked);
f.Ship.MovementDirection = float2(0, -1);
f.Ship.Update(0.01f);
Assert.Equal(1f, f.Reverse.Axis);
Assert.Equal(0f, f.Forward.Axis);
f.Ship.MovementDirection = float2(1, 0);
f.Ship.Update(0.01f);
Assert.All(f.Right, t => Assert.Equal(1f, t.Axis));
Assert.All(f.Left, t => Assert.Equal(0f, t.Axis));
Assert.Equal(0f, f.Forward.Axis);
f.Ship.MovementDirection = float2(-1, 0);
f.Ship.Update(0.01f);
Assert.All(f.Left, t => Assert.Equal(1f, t.Axis));
Assert.All(f.Right, t => Assert.Equal(0f, t.Axis));
f.Ship.MovementDirection = float2(0, 1);
f.Ship.Update(0.01f);
Assert.Equal(1f, f.Forward.Axis);
Assert.Equal(0f, f.Reverse.Axis);
}
// Finding zero-stick-unpinned. Mutation: float2(0, length(MovementDirection)) as the locked intent. The lock
// supplies full forward whatever the stick's magnitude, including none and a short reverse push.
[Fact]
public void ALockedShipGetsFullForwardWhateverTheStickLength()
{
var f = Build(100f);
using var _ = f.Cache;
f.ActivateLock();
foreach (var intent in new[] { float2(0, 0), float2(.3f, .2f), float2(-.5f, -.5f) })
{
f.Ship.MovementDirection = intent;
f.Ship.Turn = 0;
f.Ship.Update(0.01f);
Assert.Equal(1f, f.Forward.Axis);
Assert.Equal(0f, f.Reverse.Axis);
Assert.All(f.Right, t => Assert.Equal(0f, t.Axis));
Assert.All(f.Left, t => Assert.Equal(0f, t.Axis));
}
}
// Finding ccw-turn-unpinned. Mutation: drop the counter-clockwise Turn. Turn in either sign reaches only the
// thrusters with torque of that sign, at full, while the lock holds forward.
[Fact]
public void ALockedShipTurnsBothWays()
{
var f = Build(100f);
using var _ = f.Cache;
f.ActivateLock();
var all = f.Ship.GetBehaviors<Thruster>().ToArray();
var cw = all.Where(t => t.Torque > 0 && t.Item.EquippableItem.Rotation == ItemRotation.None).ToArray();
var ccw = all.Where(t => t.Torque < 0 && t.Item.EquippableItem.Rotation == ItemRotation.None).ToArray();
Assert.NotEmpty(cw);
Assert.NotEmpty(ccw);
f.Ship.MovementDirection = float2(1, -1);
foreach (var turn in new[] { 1f, -1f })
{
f.Ship.Turn = turn;
f.Ship.Update(0.01f);
Assert.All(turn > 0 ? cw : ccw, t => Assert.Equal(1f, t.Axis));
Assert.All(turn > 0 ? ccw : cw, t => Assert.Equal(0f, t.Axis));
Assert.Equal(1f, f.Forward.Axis);
}
}
// Finding non-lock-consumable-unpinned. Mutation: any active consumable locks. A consumable without
// ThrottleLockData leaves the pilot's intent in force.
[Fact]
public void AConsumableWithoutTheLockDoesNotLock()
{
var f = Build(100f);
using var _ = f.Cache;
var plain = new ConsumableItemData { Name = "Plain", Duration = 100f };
f.Cache.Upsert(plain);
f.Ship.ActivateConsumable(new ConsumableItem
{
Data = f.Cache.RefOf<ItemData>(plain),
Lot = f.Items.Lots.Add(new Lot { Design = f.Cache.RefOf<ItemData>(plain), Origin = new Attributed(), Quality = .5f, Roles = new List<RoleFill>() })
});
Assert.False(f.Ship.ThrottleLocked);
f.Ship.MovementDirection = float2(0, -1);
f.Ship.Update(0.01f);
Assert.Equal(1f, f.Reverse.Axis);
Assert.Equal(0f, f.Forward.Axis);
}
// Finding lock-end-boundary-unpinned, finding lock-end-at-90pct-unpinned. Mutations: the lock ends at half
// Duration, at 0.8 or 0.9 of Duration, or one tick late. Duration 1 stepped in 0.25 s ticks (exact in
// floats) locks the axes on exactly four ticks (reverse stays 0), the effect is gone when the fourth ends,
// and the fifth tick follows the stick. The count scales with Duration: 2 s locks eight ticks.
[Theory]
[InlineData(1f, 4)]
[InlineData(2f, 8)]
public void TheLockEndsAtTheExactTickOfItsDuration(float duration, int lockedTicks)
{
var f = Build(duration);
using var _ = f.Cache;
f.ActivateLock();
f.Ship.MovementDirection = float2(0, -1);
for (var i = 0; i < lockedTicks; i++)
{
Assert.True(f.Ship.ThrottleLocked);
f.Ship.Update(.25f);
Assert.Equal(0f, f.Reverse.Axis);
Assert.Equal(1f, f.Forward.Axis);
}
Assert.False(f.Ship.ThrottleLocked);
f.Ship.Update(.25f);
Assert.Equal(1f, f.Reverse.Axis);
Assert.Equal(0f, f.Forward.Axis);
}
}

View file

@ -0,0 +1,431 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using CultMath;
using GameCult.Caching;
using GameCult.Caching.MessagePack;
using MessagePack;
using UniRx;
using Xunit;
using static CultMath.math;
using float2 = CultMath.float2;
using float3 = CultMath.float3;
using int2 = CultMath.int2;
// Vapour clouds in the sim (aetheria-release cut vapour-cloud, ruling vapour-cloud-obscuration): a VapourDump
// behaviour vents a zone cloud that thins every sensor sight line through it, both ways, through Sensor.Gain's
// one detection rule; a lock falls by the existing detection-threshold rule and nothing else. The scene is two
// ships facing each other along z with a fixed visibility, so what a sensor gathers is a number the clouds
// alone change: steady info is visibility x sensitivity / (distance x TargetInfoDecay), and visibility 100,
// sensitivity 1, decay .5 puts it at .5 at range 400 (above the .1 threshold) and saturated at range 40.
public sealed class VapourCloudTests : IDisposable
{
private const float Dt = .05f;
private const float Threshold = .1f;
private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-vapour-" + Guid.NewGuid().ToString("N"));
private readonly List<CultCache> _caches = new List<CultCache>();
private static readonly object Glow = new object();
public VapourCloudTests() => Directory.CreateDirectory(_root);
public void Dispose()
{
foreach (var c in _caches) c.Dispose();
Directory.Delete(_root, true);
}
private static PerformanceStat Constant(float v) => new PerformanceStat { Min = v, Max = v };
private static Shape Solid(int w, int h)
{
var shape = new Shape(w, h);
foreach (var cell in shape.AllCoordinates) shape[cell] = true;
return shape;
}
private sealed class Lab
{
public ItemManager Items;
public Zone Zone;
public Ship Observer;
public Ship Target;
public EquippedItem Dump;
public LockWeapon Lock;
public int Lot = 1;
public bool HoldTarget = true;
}
private Lab Build(float distance, float radius = 20f, float opacity = .9f, float lifetime = 1000f)
{
var 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(),
TargetDetectionInfoThreshold = Threshold, TargetArmorInfoThreshold = .2f, TargetGearInfoThreshold = .8f,
TargetInfoDecay = .5f, VisibilityDecay = 0f,
FiringArc = 170, CommitHorizon = .5f, SchematicCellSize = 1f
};
var cache = AetheriaStores.Open(Path.Combine(_root, Guid.NewGuid().ToString("N") + ".cc"), catalogWritable: true);
_caches.Add(cache);
cache.Upsert(new TestCatalogGlobal { Name = "Temperament" });
cache.Upsert(new HullData
{
Name = "Hull", HullType = HullType.Ship, Shape = Solid(5, 5), Durability = 1000000, Mass = 1000,
Hardpoints = { new HardpointData { Type = HardpointType.Sensors, Position = new int2(0, 0), Shape = new Shape() } }
});
GearData Gear(string name, HardpointType hardpoint, BehaviorData behavior) => new GearData
{
Name = name, Hardpoint = hardpoint, Shape = new Shape(), Durability = 1,
MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000,
Behaviors = { behavior }
};
cache.Upsert(Gear("Eye", HardpointType.Tool, new SensorData
{
Sensitivity = Constant(1f),
SensitivityCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } },
PingBoost = Constant(.000004f), PingEnergy = Constant(0f), PingVisibility = Constant(5f),
PingRange = Constant(300f), PingCooldown = Constant(4f)
}));
cache.Upsert(Gear("Vent", HardpointType.Tool, new VapourDumpData
{
Radius = Constant(radius), Opacity = Constant(opacity), Lifetime = Constant(lifetime)
}));
cache.Upsert(Gear("LockGun", HardpointType.Sensors, new LockWeaponData
{
Damage = Constant(0), Range = Constant(1000), MinRange = Constant(0), Velocity = Constant(0),
Spread = Constant(0), DamageSpread = Constant(0), Penetration = Constant(0), Count = Constant(1),
BurstTime = Constant(0), Cooldown = Constant(1000),
DamageCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } },
LockSpeed = Constant(1), SensorImpact = Constant(1), LockAngle = Constant(170),
DirectionImpact = Constant(1), Decay = Constant(1)
}));
cache.FlushAsync().Wait();
var ledger = new ProvenanceLedger();
for (var i = 1; i <= 50; 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 = 5000 }, new GalaxyZone { Name = "Vapour", Owner = null }, null);
var lab = new Lab { Items = items, Zone = zone };
EquippableItem Make(string name) => new EquippableItem
{
Data = items.ItemData.RefOf<ItemData>(items.ItemData.GetByName<GearData>(name)), Durability = 1, Lot = lab.Lot++
};
Ship NewShip()
{
var hull = items.ItemData.RefOf<ItemData>(items.ItemData.GetByName<HullData>("Hull"));
var ship = new Ship(items, zone, new EquippableItem { Data = hull, Durability = 1000000, Lot = lab.Lot++ }, new EntitySettings());
Assert.True(ship.TryEquip(Make("Eye")));
return ship;
}
lab.Observer = NewShip();
Assert.True(lab.Observer.TryEquip(Make("LockGun"), new int2(0, 0)));
lab.Target = NewShip();
var dump = Make("Vent");
Assert.True(lab.Target.TryEquip(dump));
lab.Dump = lab.Target.Equipment.Single(x => x.EquippableItem == dump);
// Held off until a test pulls the trigger: the dump vents on its first run.
lab.Dump.Enabled.Value = false;
zone.Entities.Add(lab.Observer);
zone.Entities.Add(lab.Target);
lab.Observer.Activate();
lab.Target.Activate();
lab.Observer.Position = float3(0, 0, 0);
lab.Observer.Aim = float3(0, 0, 1);
lab.Target.Position = float3(0, 0, distance);
lab.Target.Aim = float3(0, 0, -1);
lab.Observer.SetIff(lab.Target, true);
lab.Target.SetIff(lab.Observer, true);
zone.Update(0f);
lab.Lock = lab.Observer.Weapons.OfType<LockWeapon>().Single();
return lab;
}
// One tick: both ships stay where they were put, facing each other, each a fixed visibility to the other's sensor.
private static void Tick(Lab lab)
{
foreach (var ship in new[] { lab.Observer, lab.Target })
{
ship.VisibilitySources[Glow] = 100f;
if (ship != lab.Target || lab.HoldTarget) ship.Velocity = float2.zero;
}
lab.Observer.Direction = float2(0, 1);
lab.Target.Direction = float2(0, -1);
lab.Zone.Update(Dt);
}
private static void Run(Lab lab, int ticks)
{
for (var i = 0; i < ticks; i++) Tick(lab);
}
private static float Info(Ship watcher, Ship watched) => watcher.EntityInfoGathered[watched];
// The observer has settled on the target and holds a full lock on it: the state a cloud has to break.
private static void Establish(Lab lab)
{
Run(lab, 100);
Assert.True(Info(lab.Observer, lab.Target) > Threshold, "the observer never gathered the target");
Assert.Contains(lab.Target, lab.Observer.VisibleEnemies);
lab.Observer.SetTarget(lab.Target);
Run(lab, 100);
Assert.True(lab.Lock.IsLocked, "the observer never locked the target");
Assert.Empty(lab.Zone.Clouds);
}
private static void Vent(Lab lab)
{
lab.Dump.Enabled.Value = true;
for (var i = 0; i < 5 && lab.Zone.Clouds.Count == 0; i++) Tick(lab);
Assert.Single(lab.Zone.Clouds);
}
private static VapourCloud Hand(Zone zone, float2 at, float radius, float opacity, float lifetime = 1000f)
{
var cloud = new VapourCloud { Body = new KinematicBody { Position = at }, Radius = radius, Opacity = opacity, Lifetime = lifetime };
zone.Vent(cloud);
return cloud;
}
[Fact]
public void ACloudBreaksALockAtRange()
{
var lab = Build(400f);
Establish(lab);
Vent(lab);
var ticks = 0;
while (ticks++ < 600 && !lab.Observer.Target.Value.IsNone) Tick(lab);
Assert.True(lab.Observer.Target.Value.IsNone, "the cloud never broke the target");
Assert.True(Info(lab.Observer, lab.Target) < Threshold, $"info {Info(lab.Observer, lab.Target)} is above the threshold");
Run(lab, 3);
Assert.Equal(0f, lab.Lock.Lock);
Assert.DoesNotContain(lab.Target, lab.Observer.VisibleEnemies);
}
[Fact]
public void AStrongCloseSensorBurnsThrough()
{
var lab = Build(40f);
Establish(lab);
var before = Info(lab.Observer, lab.Target);
Vent(lab);
Run(lab, 600);
var after = Info(lab.Observer, lab.Target);
Assert.True(after < before - .2f, $"the cloud thinned nothing: {before} -> {after}");
Assert.True(after > Threshold, $"info {after} fell below the threshold");
Assert.Equal(lab.Target, lab.Observer.Target.Value.Entity);
Assert.True(lab.Lock.IsLocked);
}
[Fact]
public void TheCloudBlindsItsVenter()
{
var lab = Build(400f);
Run(lab, 100);
Assert.True(Info(lab.Target, lab.Observer) > Threshold, "the venter never gathered the observer");
Assert.Contains(lab.Observer, lab.Target.VisibleEnemies);
Vent(lab);
Run(lab, 300);
Assert.True(Info(lab.Target, lab.Observer) < Threshold);
Assert.DoesNotContain(lab.Observer, lab.Target.VisibleEnemies);
// (The observer updates before the venter in the tick that vents, so it is one tick behind.)
Assert.Equal(Info(lab.Observer, lab.Target), Info(lab.Target, lab.Observer), 3);
}
[Fact]
public void ACloudFadesAndIsRemoved()
{
var lab = Build(400f);
// Vented 3s into the zone's life: the fade runs from the vent, not from the zone's start.
for (var i = 0; i < 3; i++) lab.Zone.Update(1f);
var cloud = Hand(lab.Zone, float2(0, 0), 10f, .8f, lifetime: 10f);
var a = float2(-5, 0);
var b = float2(5, 0);
Assert.Equal(.2f, lab.Zone.Obscuration(a, b), 5);
for (var i = 0; i < 5; i++) lab.Zone.Update(1f);
Assert.Equal(.4f, cloud.OpacityAt(lab.Zone.Time), 5);
Assert.Equal(.6f, lab.Zone.Obscuration(a, b), 5);
Assert.Single(lab.Zone.Clouds);
for (var i = 0; i < 5; i++) lab.Zone.Update(1f);
Assert.Empty(lab.Zone.Clouds);
Assert.Equal(1f, lab.Zone.Obscuration(a, b));
}
[Fact]
public void ACloudDriftsWithItsVelocityUntilItFades()
{
var lab = Build(400f);
var cloud = new VapourCloud { Body = new KinematicBody { Position = float2(0, 0), Velocity = float2(10, 0) }, Radius = 5f, Opacity = .5f, Lifetime = 1000f };
lab.Zone.Vent(cloud);
Assert.Equal(.5f, lab.Zone.Obscuration(float2(0, -20), float2(0, 20)), 5);
lab.Zone.Update(1f);
// The launch velocity decays on the zone's launch drag, so it has moved most of the way and no further.
Assert.InRange(cloud.Body.Position.x, 5f, 9.9f);
Assert.Equal(1f, lab.Zone.Obscuration(float2(0, -20), float2(0, 20)));
Assert.Equal(.5f, lab.Zone.Obscuration(float2(cloud.Body.Position.x, -20), float2(cloud.Body.Position.x, 20)), 2);
}
[Fact]
public void ACloudWithNoLifetimeObscuresNothing()
{
var lab = Build(400f);
var cloud = Hand(lab.Zone, float2(0, 0), 10f, .8f, lifetime: 0f);
Assert.Equal(0f, cloud.OpacityAt(lab.Zone.Time));
Assert.Equal(1f, lab.Zone.Obscuration(float2(-5, 0), float2(5, 0)));
}
[Fact]
public void AnEffectVentsOnce()
{
var lab = Build(400f, radius: 17f, opacity: .7f, lifetime: 123f);
lab.HoldTarget = false;
Run(lab, 2);
lab.Target.Velocity = float2(3, 0);
VapourCloud vented = null;
float2 venterVelocity = float2.zero;
float2 venterAt = float2.zero;
float2 cloudVelocity = float2.zero;
float2 cloudAt = float2.zero;
using var sub = lab.Zone.Clouds.ObserveAdd().Subscribe(add =>
{
vented = add.Value;
cloudVelocity = add.Value.Body.Velocity;
cloudAt = add.Value.Body.Position;
venterVelocity = lab.Target.Velocity;
venterAt = lab.Target.Position.xz;
});
lab.Dump.Enabled.Value = true;
Run(lab, 100);
Assert.Single(lab.Zone.Clouds);
Assert.Same(vented, lab.Zone.Clouds.Single());
Assert.True(length(venterVelocity) > 0f, "the venter was not moving");
Assert.Equal(venterVelocity, cloudVelocity);
Assert.Equal(venterAt, cloudAt);
Assert.Same(lab.Target, vented.Venter);
// Each stat is the authored one, evaluated through the host.
Assert.Equal(17f, vented.Radius);
Assert.Equal(.7f, vented.Opacity);
Assert.Equal(123f, vented.Lifetime);
}
[Fact]
public void ACloudThinsOnlyTheSightLinesItTouches()
{
var lab = Build(400f);
var first = Hand(lab.Zone, float2(0, 0), 10f, .6f);
float Through(float2 a, float2 b) => lab.Zone.Obscuration(a, b);
Assert.Equal(.4f, Through(float2(-50, 0), float2(50, 0)), 5);
Assert.Equal(.4f, Through(float2(50, 0), float2(-50, 0)), 5);
// Passing wide of the centre but within the radius, neither end inside the disc.
Assert.Equal(.4f, Through(float2(-50, 9.9f), float2(50, 9.9f)), 5);
Assert.Equal(1f, Through(float2(-50, 10.1f), float2(50, 10.1f)));
// Ending inside the disc, and a point inside it (the observer inside its own cloud).
Assert.Equal(.4f, Through(float2(-50, 0), float2(5, 0)), 5);
Assert.Equal(.4f, Through(float2(3, 0), float2(3, 0)), 5);
Assert.Equal(1f, Through(float2(30, 0), float2(30, 0)));
// A segment that stops short of the disc, or starts past it, touches nothing even on the same line.
Assert.Equal(1f, Through(float2(-50, 0), float2(-20, 0)));
Assert.Equal(1f, Through(float2(20, 0), float2(50, 0)));
// Two clouds on one sight line multiply.
var second = Hand(lab.Zone, float2(30, 0), 5f, .5f);
Assert.NotEqual(first.Id, second.Id);
Assert.Equal(.2f, Through(float2(-50, 0), float2(50, 0)), 5);
Assert.Equal(.4f, Through(float2(-50, 0), float2(15, 0)), 5);
}
// Where the rule is decided: once the start-up seed has decayed away, the info a sensor settles at is what
// survives the cloud times what the open sight line settles at, in both directions.
[Fact]
public void TheGainIsScaledByWhatSurvivesTheCloud()
{
var clear = Build(400f);
var cloudy = Build(400f);
// Mid-way between them and clear of both: only the segment, not either end, touches it.
Hand(cloudy.Zone, float2(0, 200), 10f, .6f, lifetime: 1e6f);
Run(clear, 400);
Run(cloudy, 400);
Assert.True(Info(clear.Observer, clear.Target) > Threshold);
Assert.Equal(.4f, Info(cloudy.Observer, cloudy.Target) / Info(clear.Observer, clear.Target), 2);
Assert.Equal(.4f, Info(cloudy.Target, cloudy.Observer) / Info(clear.Target, clear.Observer), 2);
}
// A ping is gathered through the same rule. Settled, the tick that a ping reaches the target adds the ping gain
// in place of the passive one; that extra, in a lab with a cloud on the sight line, is what survives the
// cloud times the extra in the open (info is linear in the gain while nothing saturates).
[Fact]
public void ThePingGainIsScaledToo()
{
float PingExtra(Lab lab)
{
Run(lab, 400);
lab.Observer.Sensor.Ping();
var biggest = 0f;
var last = Info(lab.Observer, lab.Target);
for (var i = 0; i < 38; i++)
{
Tick(lab);
biggest = max(biggest, Info(lab.Observer, lab.Target) - last);
last = Info(lab.Observer, lab.Target);
}
return biggest;
}
var clear = Build(250f);
var cloudy = Build(250f);
Hand(cloudy.Zone, float2(0, 125), 10f, .6f, lifetime: 1e6f);
var open = PingExtra(clear);
Assert.True(open > .05f, $"the ping left no mark on the open trace ({open})");
Assert.Equal(.4f, PingExtra(cloudy) / open, 2);
}
// The chunk path reads the same rule: the passive rate toward a point is scaled by the same survivor fraction.
[Fact]
public void TheChunkRateIsScaledByTheSameFactor()
{
var lab = Build(100f);
var at = float2(0, 100);
var open = lab.Observer.Sensor.PassiveRate(100f, at);
Assert.True(open > 0f);
Hand(lab.Zone, float2(0, 50), 10f, .6f);
Assert.Equal(.4f, lab.Observer.Sensor.PassiveRate(100f, at) / open, 5);
}
// The dump's wire shape: BehaviorData union 42, its radius, opacity and lifetime at keys 1, 2 and 3.
[Fact]
public void TheDumpIsUnion42WithRadiusOpacityLifetimeAtKeys1To3()
{
var options = CultDocumentMessagePackSerialization.OptionsFor(typeof(ZonePack).Assembly);
var data = new VapourDumpData { Radius = Constant(1f), Opacity = Constant(2f), Lifetime = Constant(3f) };
var bytes = MessagePackSerializer.Serialize<BehaviorData>(data, options);
var reader = new MessagePackReader(bytes);
Assert.Equal(2, reader.ReadArrayHeader());
Assert.Equal(42, reader.ReadInt32());
Assert.Equal(4, reader.ReadArrayHeader());
reader.Skip();
Assert.Equal(1f, MessagePackSerializer.Deserialize<PerformanceStat>(ref reader, options).Min);
Assert.Equal(2f, MessagePackSerializer.Deserialize<PerformanceStat>(ref reader, options).Min);
Assert.Equal(3f, MessagePackSerializer.Deserialize<PerformanceStat>(ref reader, options).Min);
}
}