From 8adda0b3deb1f316972470819130ad45a08ddde8 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 12 Oct 2026 03:58:34 +0200 Subject: [PATCH 1/5] FireControl publishes every landed impact on the receiver's Capabilities; Feedback.Cue and DrawAhead.Carried AbsorbEvent gains AbsorbLayer. Entity.Capabilities, four publish sites in ApplyDirectHit and Detonate (each before the damage it reports), Feedback.Cue, DrawAhead.Carried (Impact delegates), PlayerGraphicsSettings.ScreenShake. Not yet built or tested; tests follow in the next commit. Co-Authored-By: Claude Opus 5.5 --- .../Scripts/Editor/ShieldPanelCut3Verify.cs | 2 +- .../Scripts/ServerShared/CapabilityEvents.cs | 35 +++++++++------- Assets/Scripts/ServerShared/DrawAhead.cs | 16 ++++++-- Assets/Scripts/ServerShared/Entity.cs | 2 + Assets/Scripts/ServerShared/Feedback.cs | 40 +++++++++++++++++++ Assets/Scripts/ServerShared/Feedback.cs.meta | 11 +++++ Assets/Scripts/ServerShared/FireControl.cs | 26 ++++++++++++ Assets/Scripts/ServerShared/PlayerSettings.cs | 2 + Assets/Scripts/UI/FieldTester.cs | 2 +- .../CapabilityEventsTests.cs | 2 +- 10 files changed, 116 insertions(+), 22 deletions(-) create mode 100644 Assets/Scripts/ServerShared/Feedback.cs create mode 100644 Assets/Scripts/ServerShared/Feedback.cs.meta diff --git a/Assets/Scripts/Editor/ShieldPanelCut3Verify.cs b/Assets/Scripts/Editor/ShieldPanelCut3Verify.cs index aa6f4b151..31348b11d 100644 --- a/Assets/Scripts/Editor/ShieldPanelCut3Verify.cs +++ b/Assets/Scripts/Editor/ShieldPanelCut3Verify.cs @@ -130,7 +130,7 @@ namespace Aetheria.EditorTools static AbsorbEvent Absorb(Vector3 worldPos, Vector3 worldDir, float energy = 5f, DamageType type = DamageType.Kinetic) - => new AbsorbEvent(worldPos.ToCultMath(), worldDir.ToCultMath(), energy, type); + => new AbsorbEvent(worldPos.ToCultMath(), worldDir.ToCultMath(), energy, type, AbsorbLayer.Shield); // ============================================================== // D17/D20: a panel struck in its own spawn frame must absorb that hit, not swallow it. diff --git a/Assets/Scripts/ServerShared/CapabilityEvents.cs b/Assets/Scripts/ServerShared/CapabilityEvents.cs index 9e3c26635..8ee9edc55 100644 --- a/Assets/Scripts/ServerShared/CapabilityEvents.cs +++ b/Assets/Scripts/ServerShared/CapabilityEvents.cs @@ -7,30 +7,35 @@ using UniRx; using CultMath; // Capability event contract (docs/headless-playground-cut.md fork L, operator ruling 2026-09-17): -// pickup, impact absorption, melee blade and propulsion are separate item capabilities. Simulation -// (not yet built -- this file only stands up the wire shape) will own each capability's rules and -// emit these events; any number of presentations (the field shield today, later an IFS shatter -// plane or an advected particle field) subscribe to the event kinds they visualize and decide -// nothing about the rule. This file has no engine dependency: it compiles into the headless -// Aetheria.Shared build (see Aetheria.Shared.csproj) with no publisher wired up yet. +// pickup, impact absorption, melee blade and propulsion are separate item capabilities. Simulation owns +// each capability's rules and emits these events (FireControl publishes Absorb; the others are not yet +// built); any number of presentations (the field shield today, later an IFS shatter plane or an advected +// particle field) subscribe to the event kinds they visualize and decide nothing about the rule. This file +// has no engine dependency: it compiles into the headless Aetheria.Shared build (see Aetheria.Shared.csproj). -// One absorbed impact: a hit landing on the item's shield/armor capability. DamageType rides along -// so presenters can vary visuals per type; the damage-type mask itself belongs to the absorb -// capability's own data and is out of scope for this cut (operator, 2026-09-17) -- this struct only -// carries the hit's existing type through, unfiltered. +// Which layer of the receiver took an absorbed impact. +public enum AbsorbLayer { Shield, Hull } + +// One absorbed impact on a shield or a hull, published once per receiver by FireControl on that entity's +// Entity.Capabilities. Position is a world point on the receiver's sim pose at the tick the impact lands (a +// presenter draws it with DrawAhead.Carried); Direction is the way the impact travels. DamageType rides along +// so presenters can vary visuals per type; the damage-type mask itself belongs to the absorb capability's own +// data and is out of scope (operator, 2026-09-17) -- this struct only carries the hit's existing type through. public readonly struct AbsorbEvent { public readonly float3 Position; public readonly float3 Direction; public readonly float Magnitude; public readonly DamageType DamageType; + public readonly AbsorbLayer Layer; - public AbsorbEvent(float3 position, float3 direction, float magnitude, DamageType damageType) + public AbsorbEvent(float3 position, float3 direction, float magnitude, DamageType damageType, AbsorbLayer layer) { Position = position; Direction = direction; Magnitude = magnitude; DamageType = damageType; + Layer = layer; } } @@ -101,10 +106,10 @@ public readonly struct ThrustEvent } } -// Per-entity (or per-item) source of capability events. One instance is owned by whatever will -// eventually decide these rules; today the FieldShieldTest scene stands in for that owner. Follows -// the Subject-per-event-kind idiom already used on Entity (IncomingHit, HullDamage, etc): each -// event kind gets its own stream, and a subscriber to one kind never sees another. +// Per-entity source of capability events: one instance per Entity (Entity.Capabilities), published by +// FireControl for Absorb; the FieldShieldTest scene keeps its own instance. Follows the Subject-per-event-kind +// idiom already used on Entity (IncomingHit, HullDamage, etc): each event kind gets its own stream, and a +// subscriber to one kind never sees another. public class CapabilityEvents { private readonly Subject _absorb = new Subject(); diff --git a/Assets/Scripts/ServerShared/DrawAhead.cs b/Assets/Scripts/ServerShared/DrawAhead.cs index 13da8aae9..076a2ff2e 100644 --- a/Assets/Scripts/ServerShared/DrawAhead.cs +++ b/Assets/Scripts/ServerShared/DrawAhead.cs @@ -57,14 +57,22 @@ public static class DrawAhead return drawn + (end - drawn) * steer; } - // The impact cell's centre on the target's drawn pose: the cell at the sim pose, drawn ahead by the target's last - // step velocity and turned by its last step turn for lead seconds, as Position and Rotation draw the entity. + // The impact cell's centre on the target's drawn pose. public static float3 Impact(Entity target, int2 cell, float lead) + { + var world = target.ToWorldPoint((float2) cell); + return Carried(target, float3(world.x, target.Position.y, world.y), lead); + } + + // A world point on the target's sim pose, moved and turned with the target for lead seconds exactly as Position and + // Rotation draw the entity: the one owner of where a point on a hull is drawn, for the drawn round's end and for + // impact presenters. + public static float3 Carried(Entity target, float3 simPoint, float lead) { var at = Position(target, lead); - var offset = target.ToWorldPoint((float2) cell) - target.Position.xz; + var offset = float2(simPoint.x - target.Position.x, simPoint.z - target.Position.z); var turned = mul(offset, CultMath.float2x2.Rotate(Turn(target, lead))); - return float3(at.x + turned.x, at.y, at.z + turned.y); + return float3(at.x + turned.x, at.y + (simPoint.y - target.Position.y), at.z + turned.y); } // Whether the drawn flight is over by time: only a published miss ends by the clock. A hit or burst ends on ShotResolved. diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index b12eb6c7f..91607eadc 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -173,6 +173,8 @@ public abstract class Entity VisibilitySources.Values.Where(s => s.Channel == channel).Sum(s => s.Strength); public Subject IncomingHit = new Subject(); + // One per entity, created with it, never replaced: FireControl publishes each landed impact on it (Absorb). + public readonly CapabilityEvents Capabilities = new CapabilityEvents(); public Subject<(int2 pos, float damage)> ArmorDamage = new Subject<(int2, float)>(); public Subject<(EquippedItem item, float damage)> ItemDamage = new Subject<(EquippedItem, float)>(); // public Subject ItemOffline = new Subject(); diff --git a/Assets/Scripts/ServerShared/Feedback.cs b/Assets/Scripts/ServerShared/Feedback.cs new file mode 100644 index 000000000..218028052 --- /dev/null +++ b/Assets/Scripts/ServerShared/Feedback.cs @@ -0,0 +1,40 @@ +/* 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 CultMath; +using static CultMath.math; + +// What an impact presenter renders, derived once. Engine-free and never called by ServerShared: FireControl +// publishes the fact (AbsorbEvent), this derives how it reads, and every presenter (visual, audio, camera) renders +// the same cue instead of computing its own. +public readonly struct ImpactCue +{ + public readonly AbsorbLayer Layer; + public readonly float3 Position; + public readonly float3 Direction; + public readonly float Intensity; + public readonly float Shake; + + public ImpactCue(AbsorbLayer layer, float3 position, float3 direction, float intensity, float shake) + { + Layer = layer; + Position = position; + Direction = direction; + Intensity = intensity; + Shake = shake; + } +} + +public static class Feedback +{ + // Intensity is the hit's share of what the receiver's hull can take, scaled by the presenter and saturated to + // 0..1; a receiver with no hull to measure against reads 0. Shake follows intensity for the player's own ship + // only, scaled by the player's ScreenShake preference. + public static ImpactCue Cue(AbsorbEvent e, float receiverMaxDurability, bool receiverIsPlayer, float shakePreference, float intensityScale) + { + var intensity = receiverMaxDurability > 0f ? saturate(e.Magnitude / receiverMaxDurability * intensityScale) : 0f; + var shake = receiverIsPlayer ? intensity * saturate(shakePreference) : 0f; + return new ImpactCue(e.Layer, e.Position, e.Direction, intensity, shake); + } +} diff --git a/Assets/Scripts/ServerShared/Feedback.cs.meta b/Assets/Scripts/ServerShared/Feedback.cs.meta new file mode 100644 index 000000000..65f57c0d9 --- /dev/null +++ b/Assets/Scripts/ServerShared/Feedback.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 51efa531176e4871835ae88104c92c85 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/ServerShared/FireControl.cs b/Assets/Scripts/ServerShared/FireControl.cs index 261e1a4f3..4076e1fa7 100644 --- a/Assets/Scripts/ServerShared/FireControl.cs +++ b/Assets/Scripts/ServerShared/FireControl.cs @@ -1163,6 +1163,8 @@ public static class FireControl if (shot.Outcome.Shielded) { + Publish(shot.Target, AbsorbLayer.Shield, ImpactPoint(shot.Target, shot.Outcome.Cell), + float3(shot.TravelDirection.x, 0, shot.TravelDirection.y), shot.Damage, shot.DamageType); shot.Target.Shield.TakeHit(shot.DamageType, shot.Damage); return; } @@ -1206,9 +1208,31 @@ public static class FireControl for (var li = 0; li < laneCount; li++) ArrayPool.Shared.Return(buffers[li]); + Publish(target, AbsorbLayer.Hull, ImpactPoint(target, shot.Outcome.Cell), + float3(shot.TravelDirection.x, 0, shot.TravelDirection.y), shot.Damage, shot.DamageType); target.DamageHull(hullDamage); } + // Where a landed direct hit meets the receiver: the impact cell's world point on its sim pose, at its height. + private static float3 ImpactPoint(Entity target, int2 cell) + { + var world = target.ToWorldPoint((float2) cell); + return float3(world.x, target.Position.y, world.y); + } + + // The one publisher of an absorbed impact: once per receiver, on its Capabilities, before the damage it reports. + private static void Publish(Entity receiver, AbsorbLayer layer, float3 position, float3 direction, float magnitude, DamageType damageType) => + receiver.Capabilities.PublishAbsorb(new AbsorbEvent(position, direction, magnitude, damageType, layer)); + + // A blast's impact on one entity: at the blast point (on the entity's height), travelling from the blast out through + // the entity's centre, or zero when they coincide. + private static void PublishBlast(Entity entity, float2 blast, AbsorbLayer layer, float magnitude, DamageType damageType) + { + var away = entity.Position.xz - blast; + var direction = lengthsq(away) > 0f ? normalize(away) : float2.zero; + Publish(entity, layer, float3(blast.x, entity.Position.y, blast.y), float3(direction.x, 0, direction.y), magnitude, damageType); + } + // Cut 12.4(b) (docs/fire-control-cut.md, "Where P is"): a contact or delayed blast needs a committed hit -- // on a miss nothing happens, exactly like a direct hit's own gate. IncomingHit fires once, for the host, // before the blast is handed to Detonate (which then treats the host like any other entity in the radius: @@ -1591,6 +1615,7 @@ public static class FireControl { if (shield.CanTakeHit(damageType, totalShare)) { + PublishBlast(entity, worldPlanar, AbsorbLayer.Shield, totalShare, damageType); shield.TakeHit(damageType, totalShare); continue; } @@ -1620,6 +1645,7 @@ public static class FireControl for (var i = 0; i < amounts.Length; i++) hullTotal += amounts[i]; } + PublishBlast(entity, worldPlanar, AbsorbLayer.Hull, totalShare, damageType); entity.DamageHull(hullTotal); } } diff --git a/Assets/Scripts/ServerShared/PlayerSettings.cs b/Assets/Scripts/ServerShared/PlayerSettings.cs index 2566dcf59..f46bbad22 100644 --- a/Assets/Scripts/ServerShared/PlayerSettings.cs +++ b/Assets/Scripts/ServerShared/PlayerSettings.cs @@ -64,6 +64,8 @@ public class PlayerGraphicsSettings { [Key(0)] public Quality NebulaQuality = Quality.Normal; [Key(1)] public bool ShowAsteroidsInMinimap; + // How hard the camera shakes when the player's ship takes an impact: 0 off, 1 full (Feedback.Cue). + [Key(2)] public float ScreenShake = 1f; } [MessagePackObject] diff --git a/Assets/Scripts/UI/FieldTester.cs b/Assets/Scripts/UI/FieldTester.cs index 0e955e3f5..de18a33e5 100644 --- a/Assets/Scripts/UI/FieldTester.cs +++ b/Assets/Scripts/UI/FieldTester.cs @@ -73,7 +73,7 @@ public class FieldTester : MonoBehaviour { var damageType = DamageTypeCycle[_nextDamageTypeIndex]; _nextDamageTypeIndex = (_nextDamageTypeIndex + 1) % DamageTypeCycle.Length; - _capabilityEvents.PublishAbsorb(new AbsorbEvent(hit.point.ToCultMath(), ray.direction.ToCultMath(), TestField.TestMagnitude, damageType)); + _capabilityEvents.PublishAbsorb(new AbsorbEvent(hit.point.ToCultMath(), ray.direction.ToCultMath(), TestField.TestMagnitude, damageType, AbsorbLayer.Shield)); }; } diff --git a/tests/Aetheria.Shared.Tests/CapabilityEventsTests.cs b/tests/Aetheria.Shared.Tests/CapabilityEventsTests.cs index b89a07637..9876e7ad8 100644 --- a/tests/Aetheria.Shared.Tests/CapabilityEventsTests.cs +++ b/tests/Aetheria.Shared.Tests/CapabilityEventsTests.cs @@ -25,7 +25,7 @@ public sealed class CapabilityEventsTests events.Melee.Subscribe(meleed.Add); events.Thrust.Subscribe(thrust.Add); - var absorbEvent = new AbsorbEvent(float3(1, 2, 3), float3(0, 0, 1), 5f, DamageType.Corrosive); + var absorbEvent = new AbsorbEvent(float3(1, 2, 3), float3(0, 0, 1), 5f, DamageType.Corrosive, AbsorbLayer.Hull); var grabEvent = new GrabEvent(7, GrabPhase.Started, float3(1, 0, 0), 1.5f); var meleeEvent = new MeleeEvent(float3(0, 0, 1), 10f, 90f, 0.5f); var thrustEvent = new ThrustEvent(float2(1, 0), 0.5f, float2(0.2f, -0.2f)); From de7e4a026127c2557b8939835709db3bfa83a4fd Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 12 Oct 2026 03:58:37 +0200 Subject: [PATCH 2/5] ImpactFeedbackTests: direct, shield, blast, mine and ordering absorbs; Cue, ScreenShake, Carried Co-Authored-By: Claude Opus 5.5 --- .../ImpactFeedbackTests.cs | 355 ++++++++++++++++++ 1 file changed, 355 insertions(+) create mode 100644 tests/Aetheria.Shared.Tests/ImpactFeedbackTests.cs diff --git a/tests/Aetheria.Shared.Tests/ImpactFeedbackTests.cs b/tests/Aetheria.Shared.Tests/ImpactFeedbackTests.cs new file mode 100644 index 000000000..6a53173d9 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/ImpactFeedbackTests.cs @@ -0,0 +1,355 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +using System; +using System.Collections.Generic; +using System.Linq; +using CultMath; +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; + +// Feedback cut 1: FireControl publishes every landed impact once on the receiver's Capabilities, and +// DrawAhead.Carried / Feedback.Cue derive what a presenter draws. The engagement fixtures are FireControl +// Cut 12.4's (a partial of its class), so every absorb here comes from the production path: Fire, Apply, +// Detonate, Zone.StepMines. +public sealed partial class FireControlCut124Tests +{ + private static List Collect(Entity entity, List subs) + { + var list = new List(); + subs.Add(entity.Capabilities.Absorb.Subscribe(list.Add)); + return list; + } + + // A hull-only ship (no marker item, no armour): its hull loss is exactly the blast share it reports. + private Ship AddBare(Engagement e, float3 position, int lot) + { + var hullRef = e.Items.ItemData.RefOf(e.Items.ItemData.GetByName("Hull")); + var ship = new Ship(e.Items, e.Zone, new EquippableItem { Data = hullRef, Durability = 100000, Lot = lot }, new EntitySettings()); + e.Zone.Entities.Add(ship); + ship.Activate(); + ship.Position = position; + return ship; + } + + // Fires until a shot lands off the hull's centre column; returns that outcome and the absorbs the target + // published for that shot alone. + private static (ShotOutcome Outcome, List Absorbs) FireUntilOffCentreHit(Engagement e, List subs) + { + var absorbs = Collect(e.Target, subs); + ShotOutcome landed = null; + subs.Add(e.Zone.ShotResolved.Subscribe(o => landed = o)); + for (var i = 0; i < 300; i++) + { + absorbs.Clear(); + landed = null; + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + if (landed != null && landed.Hit && abs(e.Target.ToWorldPoint((float2) landed.Cell).x - e.Target.Position.x) > 2f) + return (landed, absorbs); + } + throw new Xunit.Sdk.XunitException("fixture: no off-centre hit within the attempt budget"); + } + + [Fact] + public void ADirectHullHitPublishesOneHullAbsorbAtTheImpactCell() + { + var e = Build(TestSettings(), SolidShape(21, 3), velocity: 0, damage: 100f); + var subs = new List(); + var (outcome, absorbs) = FireUntilOffCentreHit(e, subs); + foreach (var s in subs) s.Dispose(); + + var absorb = Assert.Single(absorbs); + Assert.Equal(AbsorbLayer.Hull, absorb.Layer); + var cell = e.Target.ToWorldPoint((float2) outcome.Cell); + Assert.Equal(cell.x, absorb.Position.x, 1e-4); + Assert.Equal(cell.y, absorb.Position.z, 1e-4); + Assert.Equal(e.Target.Position.y, absorb.Position.y, 1e-4); + Assert.Equal(100f, absorb.Magnitude, 1e-3); + Assert.Equal(float3(0, 0, 1), absorb.Direction); + Assert.True(abs(absorb.Position.x - e.Target.Position.x) > 1f, "fixture: the impact is more than half a cell off centre"); + } + + [Fact] + public void AShieldedHitPublishesOneShieldAbsorbAndNoHull() + { + var e = Build(TestSettings(), SolidShape(21, 3), velocity: 0, damage: 100f, equipShield: true, shieldCapacity: 100000f); + for (var i = 0; i < 30; i++) e.Zone.Update(.1f); + var subs = new List(); + var (outcome, absorbs) = FireUntilOffCentreHit(e, subs); + foreach (var s in subs) s.Dispose(); + + Assert.True(outcome.Shielded); + var absorb = Assert.Single(absorbs); + Assert.Equal(AbsorbLayer.Shield, absorb.Layer); + var cell = e.Target.ToWorldPoint((float2) outcome.Cell); + Assert.Equal(cell.x, absorb.Position.x, 1e-4); + Assert.Equal(cell.y, absorb.Position.z, 1e-4); + Assert.True(dot(absorb.Position.xz - e.Target.Position.xz, float2(0, 1)) < 0f, "the impact is on the side the shot came from"); + } + + [Fact] + public void AShieldBreakingDirectHitPublishesHull() + { + var e = Build(TestSettings(), SolidShape(21, 3), velocity: 0, damage: 100f, equipShield: true, shieldCapacity: 5f); + for (var i = 0; i < 30; i++) e.Zone.Update(.1f); + var subs = new List(); + var absorbs = Collect(e.Target, subs); + ShotOutcome landed = null; + subs.Add(e.Zone.ShotResolved.Subscribe(o => landed = o)); + for (var i = 0; i < 300 && (landed == null || !landed.Hit); i++) + { + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + } + foreach (var s in subs) s.Dispose(); + + Assert.True(landed != null && landed.Hit && landed.ShieldBroken && !landed.Shielded, "fixture: a shield-breaking hit"); + var absorb = Assert.Single(absorbs); + Assert.Equal(AbsorbLayer.Hull, absorb.Layer); + } + + [Fact] + public void AMissOrAVanishedTargetPublishesNoAbsorb() + { + var miss = Build(TestSettings(), SolidShape(5, 4), velocity: 0, damage: 100f, accuracy: 0f); + var subs = new List(); + var all = new[] { miss.Target, miss.Shooter }.SelectMany(x => Collect(x, subs)).ToList(); + var seenMiss = false; + subs.Add(miss.Zone.ShotResolved.Subscribe(o => seenMiss |= !o.Hit)); + for (var i = 0; i < 10; i++) + { + FireControl.Fire(miss.Weapon, miss.WeaponItem, miss.Shooter); + miss.Zone.Update(.01f); + } + Assert.True(seenMiss, "fixture: shots resolved as misses"); + + var gone = Build(TestSettings(), SolidShape(5, 4), velocity: 50f, damage: 100f); + var goneAbsorbs = new[] { gone.Target, gone.Shooter }.SelectMany(x => Collect(x, subs)).ToList(); + FireControl.Fire(gone.Weapon, gone.WeaponItem, gone.Shooter); + gone.Zone.Entities.Remove(gone.Target); + for (var i = 0; i < 40; i++) gone.Zone.Update(.1f); + foreach (var s in subs) s.Dispose(); + + Assert.Empty(all); + Assert.Empty(goneAbsorbs); + } + + // The blast fixture: A sits on the blast point, B beside it (partly in the disc), C far outside. + private static readonly float2 BlastAt = float2(200f, -300f); + + private (Engagement E, Ship A, Ship B, Ship C) BlastFixture() + { + var e = Build(TestSettings(), SolidShape(3, 3), velocity: 0, damage: 100f); + var y = e.Target.Position.y; + var a = AddBare(e, float3(BlastAt.x, y, BlastAt.y), 300); + var b = AddBare(e, float3(BlastAt.x + 7f, y, BlastAt.y), 302); + var c = AddBare(e, float3(BlastAt.x + 90f, y, BlastAt.y), 304); + return (e, a, b, c); + } + + private static void AssertBlastAbsorbs(Ship a, Ship b, float2 at, List aa, List bb, List cc, float aLoss, float bLoss) + { + Assert.Empty(cc); + var ea = Assert.Single(aa); + var eb = Assert.Single(bb); + Assert.Equal(AbsorbLayer.Hull, ea.Layer); + Assert.Equal(AbsorbLayer.Hull, eb.Layer); + Assert.True(aLoss > 0f && bLoss > 0f, "fixture: both hulls were damaged"); + Assert.Equal(aLoss, ea.Magnitude, 1e-3); + Assert.Equal(bLoss, eb.Magnitude, 1e-3); + Assert.True(abs(aLoss - bLoss) > 1f, "fixture: the two shares differ"); + Assert.Equal(float3(at.x, a.Position.y, at.y), ea.Position); + Assert.Equal(float3(at.x, b.Position.y, at.y), eb.Position); + Assert.Equal(float3.zero, ea.Direction); + Assert.Equal(float3(1, 0, 0), eb.Direction); + } + + [Fact] + public void ABlastPublishesOneAbsorbPerTouchedEntityWithItsShare() + { + var (e, a, b, c) = BlastFixture(); + var subs = new List(); + var aa = Collect(a, subs); var bb = Collect(b, subs); var cc = Collect(c, subs); + float aLoss = 0, bLoss = 0; + subs.Add(a.HullDamage.Subscribe(x => aLoss += x)); + subs.Add(b.HullDamage.Subscribe(x => bLoss += x)); + + FireControl.Detonate(e.Zone, BlastAt, 5f, 1000f, DamageType.Kinetic); + foreach (var s in subs) s.Dispose(); + + AssertBlastAbsorbs(a, b, BlastAt, aa, bb, cc, aLoss, bLoss); + } + + [Fact] + public void AMineBlastPublishesTheSameWay() + { + var (e, a, b, c) = BlastFixture(); + var subs = new List(); + var aa = Collect(a, subs); var bb = Collect(b, subs); var cc = Collect(c, subs); + float aLoss = 0, bLoss = 0; + subs.Add(a.HullDamage.Subscribe(x => aLoss += x)); + subs.Add(b.HullDamage.Subscribe(x => bLoss += x)); + + var mine = new Mine + { + Id = e.Zone.NextBodyId(), Body = new KinematicBody { Position = BlastAt, Velocity = float2.zero }, + ArmingDelay = 0f, FuseDelay = 0f, Lifetime = .05f, BlastRadius = 5f, Damage = 1000f, + DamageType = DamageType.Kinetic, Layer = e.Shooter, LayerFaction = e.Shooter.Faction + }; + e.Zone.Lay(mine); + for (var i = 0; i < 5 && e.Zone.Mines.Count > 0; i++) e.Zone.Update(.1f); + foreach (var s in subs) s.Dispose(); + + Assert.Empty(e.Zone.Mines); + AssertBlastAbsorbs(a, b, BlastAt, aa, bb, cc, aLoss, bLoss); + } + + [Fact] + public void ABlastShieldDecisionPicksTheLayer() + { + // A shield that can take the share: one Shield absorb, no Hull. + var e = Build(TestSettings(), SolidShape(5, 4), equipShield: true, shieldCapacity: 1000f); + for (var i = 0; i < 30; i++) e.Zone.Update(.1f); + var subs = new List(); + var takes = Collect(e.Target, subs); + FireControl.Detonate(e.Zone, e.Target.Position.xz, 2f, 50f, DamageType.Kinetic); + var take = Assert.Single(takes); + Assert.Equal(AbsorbLayer.Shield, take.Layer); + Assert.False(e.Target.Shield.Broken); + + // One that cannot: it breaks, and the hull takes the absorb. + var f = Build(TestSettings(), SolidShape(5, 4), equipShield: true, shieldCapacity: 20f); + for (var i = 0; i < 30; i++) f.Zone.Update(.1f); + var breaks = Collect(f.Target, subs); + FireControl.Detonate(f.Zone, f.Target.Position.xz, 8f, 5000f, DamageType.Kinetic); + foreach (var s in subs) s.Dispose(); + var brk = Assert.Single(breaks); + Assert.Equal(AbsorbLayer.Hull, brk.Layer); + Assert.True(f.Target.Shield.Broken); + } + + [Fact] + public void TheImpactIsPublishedBeforeItIsApplied() + { + // A lethal hull hit: the handler still sees the unharmed hull and the target still in the zone. + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, damage: 3000000f); + float? hullInHandler = null; + var inZone = false; + var hullBefore = e.Target.Hull.Durability; + using var sub = e.Target.Capabilities.Absorb.Subscribe(a => + { + hullInHandler = e.Target.Hull.Durability; + inZone = e.Zone.Entities.Contains(e.Target); + }); + var deaths = 0; + using var d = e.Target.Death.Subscribe(_ => deaths++); + for (var i = 0; i < 300 && hullInHandler == null; i++) + { + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + } + Assert.Equal(hullBefore, hullInHandler); + Assert.True(inZone); + Assert.Equal(1, deaths); + Assert.False(e.Zone.Entities.Contains(e.Target)); + + // A shield absorb: the handler sees the shield before TakeHit has used the reserve. + var f = Build(TestSettings(), SolidShape(5, 4), velocity: 0, damage: 600f, equipShield: true, shieldCapacity: 1000f); + for (var i = 0; i < 30; i++) f.Zone.Update(.1f); + bool? couldTakeInHandler = null; + using var sub2 = f.Target.Capabilities.Absorb.Subscribe(a => couldTakeInHandler = f.Target.Shield.CanTakeHit(DamageType.Kinetic, 600f)); + for (var i = 0; i < 300 && couldTakeInHandler == null; i++) + { + FireControl.Fire(f.Weapon, f.WeaponItem, f.Shooter); + f.Zone.Update(.01f); + } + Assert.True(couldTakeInHandler); + Assert.False(f.Target.Shield.CanTakeHit(DamageType.Kinetic, 600f), "fixture: the hit then used the reserve"); + } + + [Fact] + public void CarriedMovesAPointWithTheDrawnHull() + { + var e = Build(TestSettings(), SolidShape(9, 5)); + var t = e.Target; + var cell = new int2(7, 3); + var world = t.ToWorldPoint((float2) cell); + var point = float3(world.x, t.Position.y, world.y); + + t.Velocity = float2(3f, -2f); + t.TurnRate = 0f; + Assert.Equal(point, DrawAhead.Carried(t, point, 0f)); + var slid = DrawAhead.Carried(t, point, .5f); + Assert.Equal(point.x + 1.5f, slid.x, 1e-4); + Assert.Equal(point.z - 1f, slid.z, 1e-4); + Assert.Equal(point.y, slid.y, 1e-4); + + t.TurnRate = .8f; + var lead = .5f; + var centre = DrawAhead.Position(t, lead); + var turned = DrawAhead.Carried(t, point, lead); + var before = float2(point.x - t.Position.x, point.z - t.Position.z); + var after = float2(turned.x - centre.x, turned.z - centre.z); + Assert.Equal(length(before), length(after), 1e-4); + Assert.Equal(abs(t.TurnRate * lead), acos(clamp(dot(before, after) / (length(before) * length(after)), -1f, 1f)), 1e-3); + + var raised = DrawAhead.Carried(t, float3(point.x, point.y + 3f, point.z), lead); + Assert.Equal(turned.y + 3f, raised.y, 1e-4); + Assert.Equal(DrawAhead.Impact(t, cell, lead), turned); + } +} + +public sealed class ImpactCueTests +{ + private static AbsorbEvent Hit(float magnitude) => + new AbsorbEvent(float3(1, 2, 3), float3(0, 0, 1), magnitude, DamageType.Kinetic, AbsorbLayer.Hull); + + [Fact] + public void IntensityIsTheHitsShareOfMaxDurability() + { + var small = Feedback.Cue(Hit(20f), 100f, false, 1f, 1f).Intensity; + var large = Feedback.Cue(Hit(20f), 400f, false, 1f, 1f).Intensity; + Assert.Equal(4f, small / large, 1e-4); + Assert.Equal(.2f, small, 1e-5); + + Assert.Equal(4f, Feedback.Cue(Hit(20f), 100f, false, 1f, .5f).Intensity / Feedback.Cue(Hit(20f), 400f, false, 1f, .5f).Intensity, 1e-4); + Assert.Equal(.1f, Feedback.Cue(Hit(20f), 100f, false, 1f, .5f).Intensity, 1e-5); + + Assert.Equal(1f, Feedback.Cue(Hit(500f), 100f, false, 1f, 1f).Intensity); + Assert.Equal(0f, Feedback.Cue(Hit(20f), 0f, false, 1f, 1f).Intensity); + + var cue = Feedback.Cue(Hit(20f), 100f, false, 1f, 1f); + Assert.Equal(float3(1, 2, 3), cue.Position); + Assert.Equal(float3(0, 0, 1), cue.Direction); + Assert.Equal(AbsorbLayer.Hull, cue.Layer); + } + + [Fact] + public void ShakeIsZeroOffThePlayerAndScalesWithPreference() + { + foreach (var pref in new[] { 0f, .5f, 1f }) + Assert.Equal(0f, Feedback.Cue(Hit(50f), 100f, false, pref, 1f).Shake); + + Assert.Equal(0f, Feedback.Cue(Hit(50f), 100f, true, 0f, 1f).Shake); + var full = Feedback.Cue(Hit(50f), 100f, true, 1f, 1f).Shake; + Assert.Equal(.5f, full, 1e-5); + Assert.Equal(full * .5f, Feedback.Cue(Hit(50f), 100f, true, .5f, 1f).Shake, 1e-5); + } + + [Fact] + public void ScreenShakeRoundTrips() + { + var options = CultDocumentMessagePackSerialization.OptionsFor(typeof(PlayerGraphicsSettings).Assembly); + Assert.Equal(1f, new PlayerGraphicsSettings().ScreenShake); + var bytes = MessagePackSerializer.Serialize(new PlayerGraphicsSettings { ScreenShake = .3f }, options); + Assert.Equal(.3f, MessagePackSerializer.Deserialize(bytes, options).ScreenShake); + } +} From 4c893b2a4589a51a3bfc28690b9caa3446cb0fb6 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 12 Oct 2026 04:33:28 +0200 Subject: [PATCH 3/5] ImpactFeedbackTests: the target steps aside after Fire so direct hits land off the hull's centre Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/ImpactFeedbackTests.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/Aetheria.Shared.Tests/ImpactFeedbackTests.cs b/tests/Aetheria.Shared.Tests/ImpactFeedbackTests.cs index 6a53173d9..6d59febfc 100644 --- a/tests/Aetheria.Shared.Tests/ImpactFeedbackTests.cs +++ b/tests/Aetheria.Shared.Tests/ImpactFeedbackTests.cs @@ -39,18 +39,21 @@ public sealed partial class FireControlCut124Tests return ship; } - // Fires until a shot lands off the hull's centre column; returns that outcome and the absorbs the target + // Fires (the target stepping aside once the aim is frozen) until a shot lands off the hull's centre column; returns that outcome and the absorbs the target // published for that shot alone. private static (ShotOutcome Outcome, List Absorbs) FireUntilOffCentreHit(Engagement e, List subs) { var absorbs = Collect(e.Target, subs); ShotOutcome landed = null; subs.Add(e.Zone.ShotResolved.Subscribe(o => landed = o)); + var home = e.Target.Position; for (var i = 0; i < 300; i++) { absorbs.Clear(); landed = null; + e.Target.Position = home; FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Target.Position = home + float3(10f, 0, 0); // a forgivable jink after the aim is frozen: the shot lands left of centre e.Zone.Update(.01f); if (landed != null && landed.Hit && abs(e.Target.ToWorldPoint((float2) landed.Cell).x - e.Target.Position.x) > 2f) return (landed, absorbs); From 6eebec78f93b425aac5d818a2cfdd4066ca95c62 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 12 Oct 2026 05:49:43 +0200 Subject: [PATCH 4/5] ImpactFeedbackTests: direct-hit tests aim at an off-centre marker item so the production lateral draw lands the cell off centre Moving the target after Fire never changed the cell: the lateral is drawn in the hull's frame about the aimed point. Builds. Co-Authored-By: Claude Opus 5.5 --- .../ImpactFeedbackTests.cs | 24 +++++++++---------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/ImpactFeedbackTests.cs b/tests/Aetheria.Shared.Tests/ImpactFeedbackTests.cs index 6d59febfc..5a7a3b345 100644 --- a/tests/Aetheria.Shared.Tests/ImpactFeedbackTests.cs +++ b/tests/Aetheria.Shared.Tests/ImpactFeedbackTests.cs @@ -39,34 +39,32 @@ public sealed partial class FireControlCut124Tests return ship; } - // Fires (the target stepping aside once the aim is frozen) until a shot lands off the hull's centre column; returns that outcome and the absorbs the target - // published for that shot alone. - private static (ShotOutcome Outcome, List Absorbs) FireUntilOffCentreHit(Engagement e, List subs) + // The shooter aims at a marker item off the hull's centre column, so the lateral draw (Gaussian about the aimed + // item's centroid) lands the impact cell there: the cell comes from Fire, Commit and Apply, not from a helper. + // Fires until a shot lands; returns that outcome and the absorbs the target published for that shot alone. + private static (ShotOutcome Outcome, List Absorbs) FireAtTheMarker(Engagement e, List subs) { + Assert.True(e.Shooter.TrySelectTargetItem(e.Custom["Marker"]), "fixture: the marker is aimable"); var absorbs = Collect(e.Target, subs); ShotOutcome landed = null; subs.Add(e.Zone.ShotResolved.Subscribe(o => landed = o)); - var home = e.Target.Position; for (var i = 0; i < 300; i++) { absorbs.Clear(); landed = null; - e.Target.Position = home; FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); - e.Target.Position = home + float3(10f, 0, 0); // a forgivable jink after the aim is frozen: the shot lands left of centre e.Zone.Update(.01f); - if (landed != null && landed.Hit && abs(e.Target.ToWorldPoint((float2) landed.Cell).x - e.Target.Position.x) > 2f) - return (landed, absorbs); + if (landed != null && landed.Hit) return (landed, absorbs); } - throw new Xunit.Sdk.XunitException("fixture: no off-centre hit within the attempt budget"); + throw new Xunit.Sdk.XunitException("fixture: no hit within the attempt budget"); } [Fact] public void ADirectHullHitPublishesOneHullAbsorbAtTheImpactCell() { - var e = Build(TestSettings(), SolidShape(21, 3), velocity: 0, damage: 100f); + var e = Build(TestSettings(), SolidShape(21, 3), velocity: 0, damage: 100f, custom: new[] { ("Marker", new Shape(), new int2(2, 1), 1f) }); var subs = new List(); - var (outcome, absorbs) = FireUntilOffCentreHit(e, subs); + var (outcome, absorbs) = FireAtTheMarker(e, subs); foreach (var s in subs) s.Dispose(); var absorb = Assert.Single(absorbs); @@ -83,10 +81,10 @@ public sealed partial class FireControlCut124Tests [Fact] public void AShieldedHitPublishesOneShieldAbsorbAndNoHull() { - var e = Build(TestSettings(), SolidShape(21, 3), velocity: 0, damage: 100f, equipShield: true, shieldCapacity: 100000f); + var e = Build(TestSettings(), SolidShape(21, 3), velocity: 0, damage: 100f, equipShield: true, shieldCapacity: 100000f, custom: new[] { ("Marker", new Shape(), new int2(2, 1), 1f) }); for (var i = 0; i < 30; i++) e.Zone.Update(.1f); var subs = new List(); - var (outcome, absorbs) = FireUntilOffCentreHit(e, subs); + var (outcome, absorbs) = FireAtTheMarker(e, subs); foreach (var s in subs) s.Dispose(); Assert.True(outcome.Shielded); From 88dfd1da303bc14b563507d2406b3cd03918679c Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 12 Oct 2026 19:31:29 +0200 Subject: [PATCH 5/5] ImpactFeedbackTests: pin shield-layer absorb fields, blast order, impact height and cue saturation edges Test-only; builds. Ports Soul's feedback-1 s2 probes as behavioural tests that observe where each rule is decided (the published AbsorbEvent, the handler's view of hull and shield, Feedback.Cue, DrawAhead.Carried). The blast shield Magnitude is read against an unshielded twin's totalShare so a scale mutant fails. Covers findings shield-layer-fields-unpinned, blast-order-unpinned, height-fixtures-at-zero and cue-saturation-edges-unpinned. Co-Authored-By: Claude Opus 5.5 --- .../ImpactFeedbackTests.cs | 117 ++++++++++++++++++ 1 file changed, 117 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/ImpactFeedbackTests.cs b/tests/Aetheria.Shared.Tests/ImpactFeedbackTests.cs index 5a7a3b345..49c34202a 100644 --- a/tests/Aetheria.Shared.Tests/ImpactFeedbackTests.cs +++ b/tests/Aetheria.Shared.Tests/ImpactFeedbackTests.cs @@ -306,6 +306,105 @@ public sealed partial class FireControlCut124Tests Assert.Equal(turned.y + 3f, raised.y, 1e-4); Assert.Equal(DrawAhead.Impact(t, cell, lead), turned); } + + [Fact] + public void AShieldLayerAbsorbCarriesTheShotsMagnitudeDirectionAndDamageType() + { + var e = Build(TestSettings(), SolidShape(21, 3), velocity: 0, damage: 100f, equipShield: true, shieldCapacity: 100000f, custom: new[] { ("Marker", new Shape(), new int2(2, 1), 1f) }); + for (var i = 0; i < 30; i++) e.Zone.Update(.1f); + var subs = new List(); + var (_, absorbs) = FireAtTheMarker(e, subs); + foreach (var s in subs) s.Dispose(); + + var shield = Assert.Single(absorbs); + Assert.Equal(AbsorbLayer.Shield, shield.Layer); + Assert.Equal(100f, shield.Magnitude, 1e-3); + Assert.Equal(float3(0, 0, 1), shield.Direction); + Assert.Equal(DamageType.Kinetic, shield.DamageType); + } + + [Fact] + public void ABlastShieldAbsorbCarriesTheShareTheBlastPointAndTheAwayDirection() + { + // The share is read against the same geometry on an unshielded twin, where the hull absorb's Magnitude is + // the same totalShare: the shield's Magnitude is derived from the blast, not constant or scaled. + var plain = Build(TestSettings(), SolidShape(5, 4)); + var subs = new List(); + var plainTakes = Collect(plain.Target, subs); + FireControl.Detonate(plain.Zone, plain.Target.Position.xz + float2(1f, 0f), 2f, 50f, DamageType.Kinetic); + var share = Assert.Single(plainTakes).Magnitude; + Assert.True(share > 0f && share <= 50f, "fixture: the blast touched the hull"); + + var f = Build(TestSettings(), SolidShape(5, 4), equipShield: true, shieldCapacity: 1000f); + for (var i = 0; i < 30; i++) f.Zone.Update(.1f); + var takes = Collect(f.Target, subs); + var at = f.Target.Position.xz + float2(1f, 0f); + FireControl.Detonate(f.Zone, at, 2f, 50f, DamageType.Kinetic); + foreach (var s in subs) s.Dispose(); + + var take = Assert.Single(takes); + Assert.Equal(AbsorbLayer.Shield, take.Layer); + Assert.Equal(share, take.Magnitude, 1e-3); + Assert.Equal(float3(at.x, f.Target.Position.y, at.y), take.Position); + Assert.Equal(float3(-1, 0, 0), take.Direction); + } + + [Fact] + public void ASecondBlastOnABrokenShieldPublishesHullNotShield() + { + var f = Build(TestSettings(), SolidShape(5, 4), equipShield: true, shieldCapacity: 20f); + for (var i = 0; i < 30; i++) f.Zone.Update(.1f); + var subs = new List(); + var takes = Collect(f.Target, subs); + FireControl.Detonate(f.Zone, f.Target.Position.xz, 8f, 5000f, DamageType.Kinetic); + FireControl.Detonate(f.Zone, f.Target.Position.xz, 8f, 5000f, DamageType.Kinetic); + foreach (var s in subs) s.Dispose(); + Assert.Equal(new[] { AbsorbLayer.Hull, AbsorbLayer.Hull }, takes.Select(x => x.Layer).ToArray()); + } + + [Fact] + public void ABlastIsPublishedBeforeItIsApplied() + { + // Hull: the handler sees the hull the blast has not yet hurt. + var (e, a, _, _) = BlastFixture(); + float? hullInHandler = null; + var before = a.Hull.Durability; + using var sub = a.Capabilities.Absorb.Subscribe(_ => hullInHandler = a.Hull.Durability); + FireControl.Detonate(e.Zone, BlastAt, 5f, 1000f, DamageType.Kinetic); + Assert.Equal(before, hullInHandler); + Assert.True(a.Hull.Durability < before, "fixture: the blast then hurt the hull"); + + // Shield: the handler sees the reserve TakeHit has not yet used. + var f = Build(TestSettings(), SolidShape(5, 4), equipShield: true, shieldCapacity: 100f); + for (var i = 0; i < 30; i++) f.Zone.Update(.1f); + bool? couldTake = null; + using var sub2 = f.Target.Capabilities.Absorb.Subscribe(_ => couldTake = f.Target.Shield.CanTakeHit(DamageType.Kinetic, 99f)); + FireControl.Detonate(f.Zone, f.Target.Position.xz, 2f, 90f, DamageType.Kinetic); + Assert.True(couldTake); + Assert.False(f.Target.Shield.CanTakeHit(DamageType.Kinetic, 99f), "fixture: the blast then used the reserve"); + } + + [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; + e.Zone.Update(.01f); + Assert.Equal(5f, e.Target.Position.y, 1e-4); + var subs = new List(); + var (_, absorbs) = FireAtTheMarker(e, subs); + foreach (var s in subs) s.Dispose(); + Assert.Equal(5f, Assert.Single(absorbs).Position.y, 1e-4); + + // A point 4 above the raised hull stays 4 above it as the hull is carried; a point at world y 9 is 4 above. + var t = e.Target; + t.Velocity = float2(3f, -2f); + t.TurnRate = .8f; + var point = float3(t.Position.x + 2f, 9f, t.Position.z + 1f); + Assert.Equal(9f, DrawAhead.Carried(t, point, 0f).y, 1e-4); + Assert.Equal(DrawAhead.Position(t, .5f).y + 4f, DrawAhead.Carried(t, point, .5f).y, 1e-4); + } } public sealed class ImpactCueTests @@ -345,6 +444,24 @@ public sealed class ImpactCueTests Assert.Equal(full * .5f, Feedback.Cue(Hit(50f), 100f, true, .5f, 1f).Shake, 1e-5); } + [Fact] + public void IntensityIsLinearInMagnitudeAndSaturatesAtAnyScale() + { + // 150 of 100 at scale .5 reads .75 (a clamp of the magnitude at max durability would read .5). + Assert.Equal(.75f, Feedback.Cue(Hit(150f), 100f, false, 1f, .5f).Intensity, 1e-5); + Assert.Equal(1f, Feedback.Cue(Hit(500f), 100f, false, 1f, .5f).Intensity, 1e-5); + Assert.Equal(.3f, Feedback.Cue(Hit(60f), 100f, false, 1f, .5f).Intensity, 1e-5); + Assert.Equal(0f, Feedback.Cue(Hit(60f), 100f, false, 1f, 0f).Intensity, 1e-5); + } + + [Fact] + public void TheShakePreferenceIsSaturatedBothWays() + { + var one = Feedback.Cue(Hit(50f), 100f, true, 1f, 1f).Shake; + Assert.Equal(one, Feedback.Cue(Hit(50f), 100f, true, 1.5f, 1f).Shake, 1e-5); + Assert.Equal(0f, Feedback.Cue(Hit(50f), 100f, true, -1f, 1f).Shake, 1e-5); + } + [Fact] public void ScreenShakeRoundTrips() {