Sync daemon authority migration

This commit is contained in:
Meta Vens 2026-06-21 09:37:54 +01:00
commit 39a0dc8737
295 changed files with 25121 additions and 7360 deletions

View file

@ -0,0 +1,61 @@
using CultMath;
using NUnit.Framework;
using static CultMath.math;
public sealed class CultMathPrimitiveTests
{
[Test]
public void Int2SupportsAetheriaGridArithmetic()
{
var cell = int2(2, 3);
Assert.AreEqual(new int2(3, 5), cell + int2(1, 2));
Assert.AreEqual(new float2(2.0f, 3.0f), (float2)cell);
}
[Test]
public void AetheriaMathRotatesItemDirectionsOnShipPlane()
{
var forward = float2(0, 1);
Assert.AreEqual(float2(-1, 0), AetheriaMath.Rotate(forward, ItemRotation.CounterClockwise));
Assert.AreEqual(float2(0, -1), AetheriaMath.Rotate(forward, ItemRotation.Reversed));
Assert.AreEqual(float2(1, 0), AetheriaMath.Rotate(forward, ItemRotation.Clockwise));
}
[Test]
public void ItemRotationDirectionsAreCultVectors()
{
Assert.AreEqual(float2(0, 1), ItemRotation.None.Direction());
Assert.AreEqual(float2(1, 0), ItemRotation.Clockwise.Direction());
Assert.AreEqual(float2(0, -1), ItemRotation.Reversed.Direction());
Assert.AreEqual(float2(-1, 0), ItemRotation.CounterClockwise.Direction());
}
[Test]
public void AetheriaMathProjectsUnityVectorsOntoShipPlane()
{
var world = new Unity.Mathematics.float3(2, 99, 3);
Assert.AreEqual(float2(2, 3), AetheriaMath.ToCultXZ(world));
Assert.AreEqual(new Unity.Mathematics.float3(2, 0, 3), AetheriaMath.ToUnityXZ(float2(2, 3)));
}
[Test]
public void AetheriaMathCalculatesStableAngles()
{
Assert.That(AetheriaMath.AngleDegrees(float2(1, 0), float2(0, 1)), Is.EqualTo(90).Within(0.0001f));
Assert.That(AetheriaMath.AngleRadians(float2(1, 0), float2(0, 1)), Is.EqualTo(PI / 2).Within(0.0001f));
Assert.That(AetheriaMath.AngleDegrees(float3(1, 0, 0), float3(1, 0, 0)), Is.EqualTo(0).Within(0.0001f));
Assert.That(AetheriaMath.AngleDegrees(float3(0, 0, 0), float3(1, 0, 0)), Is.EqualTo(0).Within(0.0001f));
}
[Test]
public void ColorMathUsesCultVectorsAndSharedUnityBridge()
{
var rgb = ColorMath.HsvToRgb(float3(0, 1, 1));
Assert.AreEqual(float3(1, 0, 0), rgb);
Assert.AreEqual(new Unity.Mathematics.float3(1, 0, 0), AetheriaMath.ToUnity(rgb));
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: d4fc4667d3155a843b610a24d40fa6f0

View file

@ -196,6 +196,8 @@ public class DaemonRuntimeDocumentTests
CollectionAssert.Contains(frame.Capabilities, AetheriaRuntimeDaemonSchemas.GameSurface);
CollectionAssert.Contains(frame.Capabilities, AetheriaRuntimeDaemonSchemas.EditorSurface);
CollectionAssert.AreEquivalent(result.OperationResult.AppliedCommandIds, frame.AppliedCommandIds);
CollectionAssert.Contains(frame.AccountedCommandIds, targetCommand.CommandId);
CollectionAssert.Contains(frame.AccountedCommandIds, movementCommand.CommandId);
Assert.AreEqual(1, frame.Run.Zones[0].Entities[0].TargetEntityIndex);
Assert.AreEqual(
AetheriaRuntimeDaemonPublicationStore.GetProviderAdvertisementPath(statePath),
@ -336,6 +338,47 @@ public class DaemonRuntimeDocumentTests
Assert.AreEqual(AetheriaRuntimeDaemonEditorSurfaceBuilder.TuiSurfaceId, genericEditorTuiSurface.Surface.Id);
}
[Test]
public void TickRunnerSkipsPreviouslyAccountedObservedCommands()
{
var statePath = Path.Combine(
Path.GetTempPath(),
"aetheria-daemon-tick-runner-tests",
Path.GetRandomFileName(),
"state.cc");
var run = RunWithTwoEntities();
var targetCommand = AetheriaRuntimeDaemonCommandDocument.Create(
AetheriaRuntimeDaemonCommandKinds.SetTarget,
"codex",
"session-tick",
40,
"zone.0.entity.0");
targetCommand.TargetEntityKey = "zone.0.entity.1";
var result = AetheriaRuntimeDaemonTickRunner.Tick(
statePath,
run,
new AetheriaRuntimeDaemonTickOptions
{
DaemonId = "test-daemon",
SessionId = "session-tick",
FrameId = 43,
SimulationTimeSeconds = 12.52,
FixedDeltaSeconds = 0.02,
ObservedCommands = new[] { targetCommand },
AccountedCommandIds = new[] { targetCommand.CommandId }
});
Assert.AreEqual(0, result.OperationResult.AppliedCommandIds.Count);
Assert.AreEqual(0, result.OperationResult.RejectedCommandIds.Count);
Assert.AreEqual(-1, run.Zones[0].Entities[0].TargetEntityIndex);
CollectionAssert.Contains(result.Frame.AccountedCommandIds, targetCommand.CommandId);
Assert.IsTrue(AetheriaRuntimeDaemonPublicationStore.TryReadHealth(statePath, out var health));
Assert.AreEqual(1, health.ObservedCommandCount);
Assert.AreEqual(0, health.AppliedCommandCount);
Assert.AreEqual(0, health.RejectedCommandCount);
}
[Test]
public void SoaViewFactoryDescribesDaemonOwnedObserverReadOnlySlabs()
{

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: da50d4a5bedc04148a6912cff6d6f02d

View file

@ -0,0 +1,108 @@
using System.Linq;
using GameCult.Aetheria.State.Unity;
using NUnit.Framework;
public class StatRecipeSurfaceCommandTests
{
private const string TestUpdatedAtUtc = "2026-06-18T00:00:00.0000000Z";
[Test]
public void ToggleConditionCreatesEnabledInfluenceForSelectedRecipe()
{
var state = StateWithRecipe();
var next = AetheriaRuntimeStatRecipes.ToggleCondition(
state,
"weapon.damage",
AetheriaRuntimeStatRecipeConditions.Range,
updatedAtUtc: TestUpdatedAtUtc);
var influence = next.SelectedRecipe.Influences.Single();
Assert.AreEqual(AetheriaRuntimeStatRecipeConditions.Range, influence.Condition);
Assert.AreEqual(AetheriaRuntimeStatRecipeOperations.Add, influence.Operation);
Assert.IsTrue(influence.Enabled);
}
[Test]
public void CycleInfluenceOperationAdvancesAddMultiplyOverride()
{
var state = AetheriaRuntimeStatRecipes.ToggleCondition(
StateWithRecipe(),
"weapon.damage",
AetheriaRuntimeStatRecipeConditions.Quality,
updatedAtUtc: TestUpdatedAtUtc);
state = AetheriaRuntimeStatRecipes.CycleInfluenceOperation(
state,
"weapon.damage",
AetheriaRuntimeStatRecipeConditions.Quality,
updatedAtUtc: TestUpdatedAtUtc);
Assert.AreEqual(AetheriaRuntimeStatRecipeOperations.Multiply, state.SelectedRecipe.Influences[0].Operation);
state = AetheriaRuntimeStatRecipes.CycleInfluenceOperation(
state,
"weapon.damage",
AetheriaRuntimeStatRecipeConditions.Quality,
updatedAtUtc: TestUpdatedAtUtc);
Assert.AreEqual(AetheriaRuntimeStatRecipeOperations.Override, state.SelectedRecipe.Influences[0].Operation);
}
[Test]
public void SetInfluenceAmountAndCurveUpdateOnlyTargetInfluence()
{
var state = AetheriaRuntimeStatRecipes.ToggleCondition(
StateWithRecipe(),
"weapon.damage",
AetheriaRuntimeStatRecipeConditions.Heat,
updatedAtUtc: TestUpdatedAtUtc);
state = AetheriaRuntimeStatRecipes.SetInfluenceAmount(
state,
"weapon.damage",
AetheriaRuntimeStatRecipeConditions.Heat,
3.5,
updatedAtUtc: TestUpdatedAtUtc);
state = AetheriaRuntimeStatRecipes.SetInfluenceCurve(
state,
"weapon.damage",
AetheriaRuntimeStatRecipeConditions.Heat,
"easeOut",
updatedAtUtc: TestUpdatedAtUtc);
var influence = state.SelectedRecipe.Influences.Single();
Assert.AreEqual(3.5, influence.Amount, 0.0001);
Assert.AreEqual("easeOut", influence.CurveLabel);
}
[Test]
public void SetPreviewConditionUpdatesRequestedPreviewAxis()
{
var next = AetheriaRuntimeStatRecipes.SetPreviewCondition(
StateWithRecipe(),
AetheriaRuntimeStatRecipeConditions.Charge,
0.25,
TestUpdatedAtUtc);
Assert.AreEqual(0.25, next.Preview.Charge, 0.0001);
Assert.AreEqual(AetheriaRuntimeStatRecipePreviewState.Default.Quality, next.Preview.Quality, 0.0001);
}
private static AetheriaRuntimeStatRecipeSurfaceState StateWithRecipe()
{
return new AetheriaRuntimeStatRecipeSurfaceState(
new[]
{
new AetheriaRuntimeStatRecipeState(
"weapon.damage",
"Weapon Damage",
10,
new AetheriaRuntimeStatInfluenceState[0])
},
"Weapon Damage",
AetheriaRuntimeStatRecipePreviewState.Default,
"");
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 45bf0c07c6a63554a949f3605ff81d13

View file

@ -0,0 +1,254 @@
using System;
using GameCult.Aetheria.State.Unity;
using NUnit.Framework;
public class StatRecipeTests
{
[Test]
public void AddModifierScalesByConditionSample()
{
var recipe = new StatRecipe
{
BaseValue = 10,
Modifiers = new[]
{
new StatRecipeModifier
{
Condition = StatConditionMask.Quality,
Operation = StatModifierOperation.Add,
Amount = 6
}
}
};
var value = recipe.Evaluate(new StatEvaluationContext(quality: 0.5f, durability: 1, heat: 1));
Assert.AreEqual(13, value, 0.0001f);
}
[Test]
public void MultiplyModifierInterpolatesFromIdentity()
{
var recipe = new StatRecipe
{
BaseValue = 10,
Modifiers = new[]
{
new StatRecipeModifier
{
Condition = StatConditionMask.Durability,
Operation = StatModifierOperation.Multiply,
Amount = 2
}
}
};
var value = recipe.Evaluate(new StatEvaluationContext(quality: 1, durability: 0.25f, heat: 1));
Assert.AreEqual(12.5f, value, 0.0001f);
}
[Test]
public void OverrideModifierInterpolatesTowardTargetValue()
{
var recipe = new StatRecipe
{
BaseValue = 10,
Modifiers = new[]
{
new StatRecipeModifier
{
Condition = StatConditionMask.Heat,
Operation = StatModifierOperation.Override,
Amount = 20
}
}
};
var value = recipe.Evaluate(new StatEvaluationContext(quality: 1, durability: 1, heat: 0.25f));
Assert.AreEqual(12.5f, value, 0.0001f);
}
[Test]
public void DependencyMaskOnlyIncludesEnabledConditions()
{
var recipe = new StatRecipe
{
Modifiers = new[]
{
new StatRecipeModifier
{
Condition = StatConditionMask.Quality,
Enabled = false
},
new StatRecipeModifier
{
Condition = StatConditionMask.Range,
Enabled = true
}
}
};
Assert.AreEqual(StatConditionMask.Range, recipe.DependencyMask);
}
[Test]
public void DisabledAndConditionlessModifiersDoNotAffectEvaluation()
{
var recipe = new StatRecipe
{
BaseValue = 10,
Modifiers = new[]
{
new StatRecipeModifier
{
Condition = StatConditionMask.Quality,
Operation = StatModifierOperation.Add,
Amount = 100,
Enabled = false
},
new StatRecipeModifier
{
Condition = StatConditionMask.None,
Operation = StatModifierOperation.Add,
Amount = 100,
Enabled = true
},
new StatRecipeModifier
{
Condition = StatConditionMask.Heat,
Operation = StatModifierOperation.Add,
Amount = 4,
Enabled = true
}
}
};
var value = recipe.Evaluate(new StatEvaluationContext(quality: 1, durability: 1, heat: 0.25f));
Assert.AreEqual(11, value, 0.0001f);
Assert.AreEqual(StatConditionMask.Heat, recipe.DependencyMask);
}
[Test]
public void ContextOverrideReplacesSingleCondition()
{
var context = new StatEvaluationContext(quality: 0.2f, durability: 0.3f, heat: 0.4f, range: 0.5f);
var next = context.WithCondition(StatConditionMask.Range, 0.75f);
Assert.AreEqual(0.2f, next.Quality, 0.0001f);
Assert.AreEqual(0.3f, next.Durability, 0.0001f);
Assert.AreEqual(0.4f, next.Heat, 0.0001f);
Assert.AreEqual(0.75f, next.Range, 0.0001f);
}
[Test]
public void RecipeTokensMapCatalogStringsToRuntimeEnums()
{
Assert.AreEqual(StatConditionMask.Quality, StatRecipeTokens.ToConditionMask("quality"));
Assert.AreEqual(StatConditionMask.PilotSkill, StatRecipeTokens.ToConditionMask("pilotSkill"));
Assert.AreEqual(StatConditionMask.None, StatRecipeTokens.ToConditionMask("unknown"));
Assert.AreEqual(StatModifierOperation.Multiply, StatRecipeTokens.ToModifierOperation("multiply"));
Assert.AreEqual(StatModifierOperation.Override, StatRecipeTokens.ToModifierOperation("override"));
Assert.AreEqual(StatModifierOperation.Add, StatRecipeTokens.ToModifierOperation("unknown"));
}
[Test]
public void LegacyStatDependencyMaskIncludesOnlyActiveLegacyInputs()
{
var stat = new PerformanceStat
{
QualityExponent = 2,
DurabilityExponentMultiplier = 0,
HeatExponentMultiplier = 3
};
Assert.AreEqual(StatConditionMask.Quality, stat.GetDependencyMask(includeHeat: false));
Assert.AreEqual(StatConditionMask.Quality | StatConditionMask.Heat, stat.GetDependencyMask(includeHeat: true));
}
[Test]
public void LegacyStatEvaluationUsesExplicitContextExponents()
{
var stat = new PerformanceStat
{
Min = 0,
Max = 100,
QualityExponent = 2,
DurabilityExponentMultiplier = 1,
HeatExponentMultiplier = 1
};
var value = stat.EvaluateRecipeOrLegacy(
new StatEvaluationContext(quality: 0.5f, durability: 0.5f, heat: 0.5f),
durabilityExponent: 2,
thermalExponent: 2,
includeHeat: true);
Assert.AreEqual(3.125f, value, 0.0001f);
}
[Test]
public void ConsumableBaselinePreservesLinearEffectivenessCompatibility()
{
var stat = new PerformanceStat
{
Min = 10,
Max = 30,
QualityExponent = 2,
DurabilityExponentMultiplier = 10
};
var value = stat.EvaluateConsumableBaseline(
new StatEvaluationContext(quality: 0.5f, durability: 0.5f, heat: 1));
Assert.AreEqual(12.5f, value, 0.0001f);
}
[Test]
public void EntityModifierApplicationLeavesBaseValueWhenNoEntityModifiersExist()
{
var stat = new PerformanceStat();
Assert.AreEqual(42f, stat.ApplyEntityModifiers(null, 42f), 0.0001f);
}
[Test]
public void BehaviorValueReaderReadsEnumFromString()
{
var value = BehaviorValue("multiply", 0);
var operation = AetheriaRuntimeBehaviorValueReader.ReadEnum(value, StatModifierOperation.Add);
Assert.AreEqual(StatModifierOperation.Multiply, operation);
}
[Test]
public void BehaviorValueReaderReadsEnumFromNumberOrFallback()
{
var value = BehaviorValue("", (int)StatModifierOperation.Override);
var unknown = BehaviorValue("", 999);
Assert.AreEqual(
StatModifierOperation.Override,
AetheriaRuntimeBehaviorValueReader.ReadEnum(value, StatModifierOperation.Add));
Assert.AreEqual(
StatModifierOperation.Add,
AetheriaRuntimeBehaviorValueReader.ReadEnum(unknown, StatModifierOperation.Add));
}
private static AetheriaRuntimeBehaviorValue BehaviorValue(string stringValue, double numberValue)
{
return new AetheriaRuntimeBehaviorValue(
"",
stringValue,
numberValue,
false,
"",
"",
Array.Empty<AetheriaRuntimeBehaviorValue>(),
Array.Empty<AetheriaRuntimeBehaviorMapEntry>());
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 53a35efb3a0d3d646a1ea27845e4391f

View file

@ -5,7 +5,9 @@
"UnityEngine.TestRunner",
"UnityEditor.TestRunner",
"Unity.Mathematics",
"Aetheria.Shared.Unity"
"CultMath",
"Aetheria.Shared.Unity",
"GameCult.Aetheria.State.Unity"
],
"includePlatforms": [
"Editor"
@ -22,4 +24,4 @@
],
"versionDefines": [],
"noEngineReferences": false
}
}

View file

@ -0,0 +1,482 @@
using System.Collections.Generic;
using NUnit.Framework;
public sealed class YmirPhysicsQueryTests
{
[Test]
public void StepIntegratesDynamicBodiesWithoutMutatingInputWorld()
{
var body = PhysicsBody("ship", 1, 2, 1);
body.velocity = Vec2(3, -4);
var world = World(body);
var result = YmirPhysicsQueries.Step(world, 0.5f);
Assert.That(result.world.time, Is.EqualTo(0.5f).Within(0.0001f));
Assert.That(result.world.bodies[0].position.x, Is.EqualTo(2.5f).Within(0.0001f));
Assert.That(result.world.bodies[0].position.y, Is.EqualTo(0.0f).Within(0.0001f));
Assert.That(world.bodies[0].position.x, Is.EqualTo(1.0f).Within(0.0001f));
Assert.That(world.bodies[0].position.y, Is.EqualTo(2.0f).Within(0.0001f));
}
[Test]
public void StepAppliesRadialFieldsWithLinearFalloff()
{
var body = PhysicsBody("ship", 5, 0, 1);
var world = World(body);
world.fields = new[]
{
new YmirRadialField
{
id = "push",
position = Vec2(0, 0),
strength = 10,
radius = 10
}
};
var result = YmirPhysicsQueries.Step(world, 1.0f);
Assert.That(result.world.bodies[0].velocity.x, Is.EqualTo(5.0f).Within(0.0001f));
Assert.That(result.world.bodies[0].position.x, Is.EqualTo(10.0f).Within(0.0001f));
}
[Test]
public void StepReportsContactsAndSeparatesDynamicBodyFromStaticBody()
{
var mover = PhysicsBody("mover", 0, 0, 1);
mover.velocity = Vec2(2, 0);
mover.mass = 1;
var wall = PhysicsBody("wall", 1.5f, 0, 1);
wall.isStatic = true;
var world = World(mover, wall);
var result = YmirPhysicsQueries.Step(world, 0.1f);
Assert.That(result.contacts, Has.Length.EqualTo(1));
Assert.AreEqual("mover", result.contacts[0].bodyA);
Assert.AreEqual("wall", result.contacts[0].bodyB);
Assert.That(result.contacts[0].normal.x, Is.EqualTo(1.0f).Within(0.0001f));
Assert.That(result.contacts[0].penetration, Is.GreaterThan(0));
Assert.That(result.world.bodies[0].position.x, Is.EqualTo(-0.5f).Within(0.0001f));
}
[Test]
public void StepSkipsInvalidInput()
{
var result = YmirPhysicsQueries.Step((YmirStepRequest)null);
Assert.That(result.world.bodies, Is.Empty);
Assert.That(result.contacts, Is.Empty);
Assert.That(YmirPhysicsQueries.Step(World(PhysicsBody("ship", 1, 0, 1)), -1).contacts, Is.Empty);
}
[Test]
public void OverlapSphereReturnsHitsSortedBySurfaceDistance()
{
var hits = YmirPhysicsQueries.OverlapSphere(
new[]
{
Body("far", 4, 0, 0, 2),
Body("near", 1, 0, 0, 0.25f),
Body("miss", 10, 0, 0, 1)
},
Vec3(0, 0, 0),
3);
Assert.That(hits, Has.Length.EqualTo(2));
Assert.AreEqual("near", hits[0].bodyId);
Assert.AreEqual("far", hits[1].bodyId);
Assert.That(hits[0].distance, Is.EqualTo(0.75f).Within(0.0001f));
Assert.That(hits[1].distance, Is.EqualTo(2.0f).Within(0.0001f));
}
[Test]
public void OverlapSphereBreaksEqualDistanceTiesByBodyId()
{
var hits = YmirPhysicsQueries.OverlapSphere(
new[]
{
Body("b", -2, 0, 0, 1),
Body("a", 2, 0, 0, 1)
},
Vec3(0, 0, 0),
3);
Assert.That(hits, Has.Length.EqualTo(2));
Assert.AreEqual("a", hits[0].bodyId);
Assert.AreEqual("b", hits[1].bodyId);
}
[Test]
public void OverlapSphereReportsPenetrationAndContactPoint()
{
var hits = YmirPhysicsQueries.OverlapSphere(
new[] { Body("target", 2, 0, 0, 1) },
Vec3(0, 0, 0),
3);
Assert.That(hits, Has.Length.EqualTo(1));
Assert.That(hits[0].penetration, Is.EqualTo(2.0f).Within(0.0001f));
Assert.That(hits[0].normal.x, Is.EqualTo(1.0f).Within(0.0001f));
Assert.That(hits[0].point.x, Is.EqualTo(1.0f).Within(0.0001f));
}
[Test]
public void OverlapSphereCanReuseCallerOwnedHitBuffer()
{
var hits = new List<YmirSphereOverlapHit> { new YmirSphereOverlapHit { bodyId = "stale" } };
var count = YmirPhysicsQueries.OverlapSphere(
new[]
{
Body("b", -2, 0, 0, 1),
Body("a", 2, 0, 0, 1)
},
Vec3(0, 0, 0),
3,
hits);
Assert.That(count, Is.EqualTo(2));
Assert.That(hits, Has.Count.EqualTo(2));
Assert.AreEqual("a", hits[0].bodyId);
Assert.AreEqual("b", hits[1].bodyId);
}
[Test]
public void OverlapSphereSkipsInvalidInput()
{
Assert.That(YmirPhysicsQueries.OverlapSphere(null, Vec3(0, 0, 0), 1), Is.Empty);
Assert.That(YmirPhysicsQueries.OverlapSphere(new[] { Body("bad", 0, 0, 0, -1) }, Vec3(0, 0, 0), 1), Is.Empty);
Assert.That(YmirPhysicsQueries.OverlapSphere(new[] { Body("target", 0, 0, 0, 1) }, Vec3(0, 0, 0), -1), Is.Empty);
}
[Test]
public void OverlapCircleReturnsHitsSortedBySurfaceDistance()
{
var hits = YmirPhysicsQueries.OverlapCircle(
new[]
{
PhysicsBody("far", 4, 0, 2),
PhysicsBody("near", 1, 0, 0.25f),
PhysicsBody("miss", 10, 0, 1)
},
Vec2(0, 0),
3);
Assert.That(hits, Has.Length.EqualTo(2));
Assert.AreEqual("near", hits[0].bodyId);
Assert.AreEqual("far", hits[1].bodyId);
Assert.That(hits[0].distance, Is.EqualTo(0.75f).Within(0.0001f));
Assert.That(hits[1].distance, Is.EqualTo(2.0f).Within(0.0001f));
}
[Test]
public void OverlapCircleBreaksEqualDistanceTiesByBodyId()
{
var hits = YmirPhysicsQueries.OverlapCircle(
new[]
{
PhysicsBody("b", -2, 0, 1),
PhysicsBody("a", 2, 0, 1)
},
Vec2(0, 0),
3);
Assert.That(hits, Has.Length.EqualTo(2));
Assert.AreEqual("a", hits[0].bodyId);
Assert.AreEqual("b", hits[1].bodyId);
}
[Test]
public void OverlapCircleReportsPenetrationAndContactPoint()
{
var hits = YmirPhysicsQueries.OverlapCircle(
new[] { PhysicsBody("target", 2, 0, 1) },
Vec2(0, 0),
3);
Assert.That(hits, Has.Length.EqualTo(1));
Assert.That(hits[0].penetration, Is.EqualTo(2.0f).Within(0.0001f));
Assert.That(hits[0].normal.x, Is.EqualTo(1.0f).Within(0.0001f));
Assert.That(hits[0].point.x, Is.EqualTo(1.0f).Within(0.0001f));
}
[Test]
public void OverlapCircleCanReuseCallerOwnedHitBuffer()
{
var hits = new List<YmirCircleOverlapHit> { new YmirCircleOverlapHit { bodyId = "stale" } };
var count = YmirPhysicsQueries.OverlapCircle(
new[]
{
PhysicsBody("b", -2, 0, 1),
PhysicsBody("a", 2, 0, 1)
},
Vec2(0, 0),
3,
hits);
Assert.That(count, Is.EqualTo(2));
Assert.That(hits, Has.Count.EqualTo(2));
Assert.AreEqual("a", hits[0].bodyId);
Assert.AreEqual("b", hits[1].bodyId);
}
[Test]
public void OverlapCircleSkipsInvalidInput()
{
Assert.That(YmirPhysicsQueries.OverlapCircle(null, Vec2(0, 0), 1), Is.Empty);
Assert.That(YmirPhysicsQueries.OverlapCircle(new[] { PhysicsBody("bad", 0, 0, -1) }, Vec2(0, 0), 1), Is.Empty);
Assert.That(YmirPhysicsQueries.OverlapCircle(new[] { PhysicsBody("target", 0, 0, 1) }, Vec2(0, 0), -1), Is.Empty);
}
[Test]
public void CastSphereReturnsHitsSortedByImpactDistance()
{
var hits = YmirPhysicsQueries.CastSphere(
new[]
{
Body("far", 8, 0, 0, 1),
Body("near", 4, 0, 0, 1),
Body("miss", 4, 4, 0, 1)
},
Vec3(0, 0, 0),
Vec3(1, 0, 0),
10,
1);
Assert.That(hits, Has.Length.EqualTo(2));
Assert.AreEqual("near", hits[0].bodyId);
Assert.AreEqual("far", hits[1].bodyId);
Assert.That(hits[0].distance, Is.EqualTo(2.0f).Within(0.0001f));
Assert.That(hits[1].distance, Is.EqualTo(6.0f).Within(0.0001f));
}
[Test]
public void CastSphereBreaksEqualDistanceTiesByBodyId()
{
var hits = YmirPhysicsQueries.CastSphere(
new[]
{
Body("b", 4, 1, 0, 1),
Body("a", 4, -1, 0, 1)
},
Vec3(0, 0, 0),
Vec3(1, 0, 0),
10,
0);
Assert.That(hits, Has.Length.EqualTo(2));
Assert.AreEqual("a", hits[0].bodyId);
Assert.AreEqual("b", hits[1].bodyId);
}
[Test]
public void CastSphereReportsImpactPointAndNormal()
{
var hits = YmirPhysicsQueries.CastSphere(
new[] { Body("target", 4, 0, 0, 1) },
Vec3(0, 0, 0),
Vec3(2, 0, 0),
10,
1);
Assert.That(hits, Has.Length.EqualTo(1));
Assert.That(hits[0].distance, Is.EqualTo(2.0f).Within(0.0001f));
Assert.That(hits[0].normal.x, Is.EqualTo(1.0f).Within(0.0001f));
Assert.That(hits[0].point.x, Is.EqualTo(3.0f).Within(0.0001f));
}
[Test]
public void CastSphereReportsInitialOverlapAtZeroDistance()
{
var hits = YmirPhysicsQueries.CastSphere(
new[] { Body("inside", 0.5f, 0, 0, 1) },
Vec3(0, 0, 0),
Vec3(1, 0, 0),
10,
1);
Assert.That(hits, Has.Length.EqualTo(1));
Assert.That(hits[0].distance, Is.EqualTo(0.0f).Within(0.0001f));
}
[Test]
public void CastSphereCanReuseCallerOwnedHitBuffer()
{
var hits = new List<YmirSphereCastHit> { new YmirSphereCastHit { bodyId = "stale" } };
var count = YmirPhysicsQueries.CastSphere(
new[]
{
Body("far", 8, 0, 0, 1),
Body("near", 4, 0, 0, 1)
},
Vec3(0, 0, 0),
Vec3(1, 0, 0),
10,
1,
hits);
Assert.That(count, Is.EqualTo(2));
Assert.That(hits, Has.Count.EqualTo(2));
Assert.AreEqual("near", hits[0].bodyId);
Assert.AreEqual("far", hits[1].bodyId);
}
[Test]
public void CastSphereSkipsInvalidInput()
{
Assert.That(YmirPhysicsQueries.CastSphere(null, Vec3(0, 0, 0), Vec3(1, 0, 0), 1, 1), Is.Empty);
Assert.That(YmirPhysicsQueries.CastSphere(new[] { Body("target", 0, 0, 0, 1) }, Vec3(0, 0, 0), Vec3(0, 0, 0), 1, 1), Is.Empty);
Assert.That(YmirPhysicsQueries.CastSphere(new[] { Body("target", 0, 0, 0, 1) }, Vec3(0, 0, 0), Vec3(1, 0, 0), -1, 1), Is.Empty);
Assert.That(YmirPhysicsQueries.CastSphere(new[] { Body("bad", 0, 0, 0, -1) }, Vec3(0, 0, 0), Vec3(1, 0, 0), 1, 1), Is.Empty);
}
[Test]
public void CastCircleReturnsHitsSortedByImpactDistance()
{
var hits = YmirPhysicsQueries.CastCircle(
new[]
{
PhysicsBody("far", 8, 0, 1),
PhysicsBody("near", 4, 0, 1),
PhysicsBody("miss", 4, 4, 1)
},
Vec2(0, 0),
Vec2(1, 0),
10,
1);
Assert.That(hits, Has.Length.EqualTo(2));
Assert.AreEqual("near", hits[0].bodyId);
Assert.AreEqual("far", hits[1].bodyId);
Assert.That(hits[0].distance, Is.EqualTo(2.0f).Within(0.0001f));
Assert.That(hits[1].distance, Is.EqualTo(6.0f).Within(0.0001f));
}
[Test]
public void CastCircleBreaksEqualDistanceTiesByBodyId()
{
var hits = YmirPhysicsQueries.CastCircle(
new[]
{
PhysicsBody("b", 4, 1, 1),
PhysicsBody("a", 4, -1, 1)
},
Vec2(0, 0),
Vec2(1, 0),
10,
0);
Assert.That(hits, Has.Length.EqualTo(2));
Assert.AreEqual("a", hits[0].bodyId);
Assert.AreEqual("b", hits[1].bodyId);
}
[Test]
public void CastCircleReportsImpactPointAndNormal()
{
var hits = YmirPhysicsQueries.CastCircle(
new[] { PhysicsBody("target", 4, 0, 1) },
Vec2(0, 0),
Vec2(2, 0),
10,
1);
Assert.That(hits, Has.Length.EqualTo(1));
Assert.That(hits[0].distance, Is.EqualTo(2.0f).Within(0.0001f));
Assert.That(hits[0].normal.x, Is.EqualTo(1.0f).Within(0.0001f));
Assert.That(hits[0].point.x, Is.EqualTo(3.0f).Within(0.0001f));
}
[Test]
public void CastCircleReportsInitialOverlapAtZeroDistance()
{
var hits = YmirPhysicsQueries.CastCircle(
new[] { PhysicsBody("inside", 0.5f, 0, 1) },
Vec2(0, 0),
Vec2(1, 0),
10,
1);
Assert.That(hits, Has.Length.EqualTo(1));
Assert.That(hits[0].distance, Is.EqualTo(0.0f).Within(0.0001f));
}
[Test]
public void CastCircleCanReuseCallerOwnedHitBuffer()
{
var hits = new List<YmirCircleCastHit> { new YmirCircleCastHit { bodyId = "stale" } };
var count = YmirPhysicsQueries.CastCircle(
new[]
{
PhysicsBody("far", 8, 0, 1),
PhysicsBody("near", 4, 0, 1)
},
Vec2(0, 0),
Vec2(1, 0),
10,
1,
hits);
Assert.That(count, Is.EqualTo(2));
Assert.That(hits, Has.Count.EqualTo(2));
Assert.AreEqual("near", hits[0].bodyId);
Assert.AreEqual("far", hits[1].bodyId);
}
[Test]
public void CastCircleSkipsInvalidInput()
{
Assert.That(YmirPhysicsQueries.CastCircle(null, Vec2(0, 0), Vec2(1, 0), 1, 1), Is.Empty);
Assert.That(YmirPhysicsQueries.CastCircle(new[] { PhysicsBody("target", 0, 0, 1) }, Vec2(0, 0), Vec2(0, 0), 1, 1), Is.Empty);
Assert.That(YmirPhysicsQueries.CastCircle(new[] { PhysicsBody("target", 0, 0, 1) }, Vec2(0, 0), Vec2(1, 0), -1, 1), Is.Empty);
Assert.That(YmirPhysicsQueries.CastCircle(new[] { PhysicsBody("bad", 0, 0, -1) }, Vec2(0, 0), Vec2(1, 0), 1, 1), Is.Empty);
}
private static YmirSphereQueryBody Body(string id, float x, float y, float z, float radius)
{
return new YmirSphereQueryBody
{
id = id,
position = Vec3(x, y, z),
radius = radius
};
}
private static YmirPhysicsBody PhysicsBody(string id, float x, float y, float radius)
{
return new YmirPhysicsBody
{
id = id,
position = Vec2(x, y),
direction = Vec2(1, 0),
radius = radius,
mass = 1,
momentOfInertia = 1
};
}
private static YmirWorld World(params YmirPhysicsBody[] bodies)
{
return new YmirWorld
{
time = 0,
bodies = bodies,
fields = new YmirRadialField[0]
};
}
private static YmirVec3 Vec3(float x, float y, float z)
{
return new YmirVec3 { x = x, y = y, z = z };
}
private static YmirVec2 Vec2(float x, float y)
{
return new YmirVec2 { x = x, y = y };
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 97ea5fc21683ebf409292b94fcb4cf6a