Merge origin/master into eureka/aetheria-release-modifier-stacking
This commit is contained in:
commit
30c008d7a8
12 changed files with 918 additions and 14 deletions
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
35
Assets/Scripts/ServerShared/Behaviors/ThrottleLock.cs
Normal file
35
Assets/Scripts/ServerShared/Behaviors/ThrottleLock.cs
Normal 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;
|
||||
}
|
||||
61
Assets/Scripts/ServerShared/Behaviors/VapourDump.cs
Normal file
61
Assets/Scripts/ServerShared/Behaviors/VapourDump.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/ServerShared/Behaviors/VapourDump.cs.meta
Normal file
11
Assets/Scripts/ServerShared/Behaviors/VapourDump.cs.meta
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 961a74683c574807a300eb26694f206d
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
32
Assets/Scripts/ServerShared/VapourClouds.cs
Normal file
32
Assets/Scripts/ServerShared/VapourClouds.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/ServerShared/VapourClouds.cs.meta
Normal file
11
Assets/Scripts/ServerShared/VapourClouds.cs.meta
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: ced45a9738974452b0065b57ee527e8a
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -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
|
||||
|
|
|
|||
274
tests/Aetheria.Shared.Tests/ThrottleLockTests.cs
Normal file
274
tests/Aetheria.Shared.Tests/ThrottleLockTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
431
tests/Aetheria.Shared.Tests/VapourCloudTests.cs
Normal file
431
tests/Aetheria.Shared.Tests/VapourCloudTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue