diff --git a/Client/Assets/Framework/Shared/ActorBehavior/BehaviorConfigCatalog.cs b/Client/Assets/Framework/Shared/ActorBehavior/BehaviorConfigCatalog.cs index 7860e55c..b0fbe58c 100644 --- a/Client/Assets/Framework/Shared/ActorBehavior/BehaviorConfigCatalog.cs +++ b/Client/Assets/Framework/Shared/ActorBehavior/BehaviorConfigCatalog.cs @@ -41,6 +41,10 @@ namespace XWorld.Framework.ActorBehavior throw new InvalidOperationException("Behavior config type mismatch: expected " + type); int version = skillRoot.OptionalInt("version", 1); + int flyVersion = flyRoot.OptionalInt("version", 1); + int buffVersion = buffRoot.OptionalInt("version", 1); + if (flyVersion != version || buffVersion != version) + throw new InvalidOperationException("Behavior config version mismatch: expected " + version); var flyObjects = ParseFlyObjects(flyRoot.OptionalArray("flyObjects")); var buffs = ParseBuffs(buffRoot.OptionalArray("buffs")); var behaviors = ParseBehaviors(skillRoot.OptionalArray("behaviors")); @@ -229,12 +233,31 @@ namespace XWorld.Framework.ActorBehavior foreach (TimelineEntrySpec entry in timeline) { if (entry.Time < 0f) throw new InvalidOperationException("Negative timeline time in " + owner); - if (entry.Kind == TimelineEntryKind.LogicEffect && !localEffects.Contains(entry.EffectId)) - throw new InvalidOperationException("Missing logic effect reference " + entry.EffectId + " in " + owner); - if (entry.Kind == TimelineEntryKind.Projectile && !_flyObjects.ContainsKey(entry.FlyObjectId)) - throw new InvalidOperationException("Missing flyObject reference " + entry.FlyObjectId + " in " + owner); - if (entry.Kind == TimelineEntryKind.Buff && !_buffs.ContainsKey(entry.BuffId)) - throw new InvalidOperationException("Missing buff reference " + entry.BuffId + " in " + owner); + if (entry.Kind == TimelineEntryKind.Animation && string.IsNullOrEmpty(entry.Animation)) + throw new InvalidOperationException("Missing animation for timeline entry in " + owner); + if (entry.Kind == TimelineEntryKind.Effect && string.IsNullOrEmpty(entry.Prefab)) + throw new InvalidOperationException("Missing prefab for timeline entry in " + owner); + if (entry.Kind == TimelineEntryKind.LogicEffect) + { + if (string.IsNullOrEmpty(entry.EffectId)) + throw new InvalidOperationException("Missing effectId for timeline entry in " + owner); + if (!localEffects.Contains(entry.EffectId)) + throw new InvalidOperationException("Missing logic effect reference " + entry.EffectId + " in " + owner); + } + if (entry.Kind == TimelineEntryKind.Projectile) + { + if (string.IsNullOrEmpty(entry.FlyObjectId)) + throw new InvalidOperationException("Missing flyObjectId for timeline entry in " + owner); + if (!_flyObjects.ContainsKey(entry.FlyObjectId)) + throw new InvalidOperationException("Missing flyObject reference " + entry.FlyObjectId + " in " + owner); + } + if (entry.Kind == TimelineEntryKind.Buff) + { + if (string.IsNullOrEmpty(entry.BuffId)) + throw new InvalidOperationException("Missing buffId for timeline entry in " + owner); + if (!_buffs.ContainsKey(entry.BuffId)) + throw new InvalidOperationException("Missing buff reference " + entry.BuffId + " in " + owner); + } } } @@ -254,10 +277,22 @@ namespace XWorld.Framework.ActorBehavior throw new InvalidOperationException("Invalid width for " + effect.Id + " in " + owner); foreach (LogicEffectOpSpec op in effect.Effects) { - if (op.Type == LogicEffectOpKind.Buff && !_buffs.ContainsKey(op.BuffId)) - throw new InvalidOperationException("Missing buff reference " + op.BuffId + " in " + owner); - if (op.Type == LogicEffectOpKind.Projectile && !_flyObjects.ContainsKey(op.FlyObjectId)) - throw new InvalidOperationException("Missing flyObject reference " + op.FlyObjectId + " in " + owner); + if (op.Type == LogicEffectOpKind.Buff) + { + if (string.IsNullOrEmpty(op.BuffId)) + throw new InvalidOperationException("Missing buffId in " + owner); + if (!_buffs.ContainsKey(op.BuffId)) + throw new InvalidOperationException("Missing buff reference " + op.BuffId + " in " + owner); + } + if (op.Type == LogicEffectOpKind.Projectile) + { + if (string.IsNullOrEmpty(op.FlyObjectId)) + throw new InvalidOperationException("Missing flyObjectId in " + owner); + if (!_flyObjects.ContainsKey(op.FlyObjectId)) + throw new InvalidOperationException("Missing flyObject reference " + op.FlyObjectId + " in " + owner); + } + if (op.Type == LogicEffectOpKind.Attribute && string.IsNullOrEmpty(op.Attribute)) + throw new InvalidOperationException("Missing attribute in " + owner); } } } diff --git a/Client/Assets/Framework/Shared/ActorBehavior/BehaviorWorld.cs b/Client/Assets/Framework/Shared/ActorBehavior/BehaviorWorld.cs index 9a79c9c6..dccffc82 100644 --- a/Client/Assets/Framework/Shared/ActorBehavior/BehaviorWorld.cs +++ b/Client/Assets/Framework/Shared/ActorBehavior/BehaviorWorld.cs @@ -251,13 +251,18 @@ namespace XWorld.Framework.ActorBehavior private void ApplyLogicEffect(int sourceActorId, BehaviorConfigCatalog catalog, LogicEffectSpec effect, BehaviorInput input, int hitActorId) { - foreach (int targetActorId in SelectTargets(sourceActorId, effect, input, hitActorId)) + ApplyLogicEffect(sourceActorId, sourceActorId, catalog, effect, input, hitActorId); + } + + private void ApplyLogicEffect(int eventSourceActorId, int originActorId, BehaviorConfigCatalog catalog, LogicEffectSpec effect, BehaviorInput input, int hitActorId) + { + foreach (int targetActorId in SelectTargets(originActorId, effect, input, hitActorId)) { _events.Add(new BehaviorEvent { Kind = BehaviorEventKind.LogicEffectApplied, - ActorId = sourceActorId, - SourceActorId = sourceActorId, + ActorId = eventSourceActorId, + SourceActorId = eventSourceActorId, TargetActorId = targetActorId, LogicEffectId = effect.Id }); @@ -270,8 +275,8 @@ namespace XWorld.Framework.ActorBehavior _events.Add(new BehaviorEvent { Kind = BehaviorEventKind.AttributeEffectRequested, - ActorId = sourceActorId, - SourceActorId = sourceActorId, + ActorId = eventSourceActorId, + SourceActorId = eventSourceActorId, TargetActorId = targetActorId, LogicEffectId = effect.Id, Attribute = op.Attribute, @@ -280,11 +285,11 @@ namespace XWorld.Framework.ActorBehavior } else if (op.Type == LogicEffectOpKind.Projectile) { - SpawnProjectile(sourceActorId, catalog, op.FlyObjectId, input); + SpawnProjectile(originActorId, catalog, op.FlyObjectId, input); } else if (op.Type == LogicEffectOpKind.Buff) { - ApplyBuff(targetActorId, catalog.Type, op.BuffId, sourceActorId); + ApplyBuff(targetActorId, catalog.Type, op.BuffId, eventSourceActorId); } } } @@ -318,7 +323,7 @@ namespace XWorld.Framework.ActorBehavior if (_options.Mode == BehaviorRunMode.ServerLogic) { for (int e = 0; e < buff.Spec.LogicEffects.Count; e++) - ApplyLogicEffect(buff.TargetActorId, buff.Catalog, buff.Spec.LogicEffects[e], BehaviorInput.FromTargetActor(buff.TargetActorId), buff.TargetActorId); + ApplyLogicEffect(buff.SourceActorId, buff.TargetActorId, buff.Catalog, buff.Spec.LogicEffects[e], BehaviorInput.FromTargetActor(buff.TargetActorId), buff.TargetActorId); } buff.NextTickTime += buff.Spec.TickInterval; } @@ -448,10 +453,12 @@ namespace XWorld.Framework.ActorBehavior if (!actor.Alive) continue; if (actor.ActorId == projectile.SourceActorId) continue; if (actor.TeamId == source.TeamId) continue; + if (projectile.HitActorIds.Contains(actor.ActorId)) continue; float distance = BehaviorVector3.DistanceXZ(projectile.Position, actor.Position); if (distance > projectile.Spec.Collision.Radius + actor.Radius) continue; projectile.HitCount++; + projectile.HitActorIds.Add(actor.ActorId); _events.Add(new BehaviorEvent { Kind = BehaviorEventKind.ProjectileHit, @@ -549,7 +556,7 @@ namespace XWorld.Framework.ActorBehavior { if (!actor.Alive) continue; if (!PassesTargetFilter(source, actor, effect.Target)) continue; - if (IsInsideShape(source, actor, effect)) + if (IsInsideShape(source, actor, effect, ResolveEffectDirection(source, input))) yield return actor.ActorId; } } @@ -565,7 +572,20 @@ namespace XWorld.Framework.ActorBehavior return true; } - private static bool IsInsideShape(BehaviorActorState source, BehaviorActorState target, LogicEffectSpec effect) + private BehaviorVector3 ResolveEffectDirection(BehaviorActorState source, BehaviorInput input) + { + if (input.Kind == BehaviorInputKind.Direction) + return input.Direction.NormalizedXZ(); + if (input.Kind == BehaviorInputKind.TargetPosition) + return (input.TargetPosition - source.Position).NormalizedXZ(); + if (input.Kind == BehaviorInputKind.TargetActor + && input.TargetActorId != 0 + && _actors.TryGetValue(input.TargetActorId, out BehaviorActorState target)) + return (target.Position - source.Position).NormalizedXZ(); + return source.Forward.NormalizedXZ(); + } + + private static bool IsInsideShape(BehaviorActorState source, BehaviorActorState target, LogicEffectSpec effect, BehaviorVector3 direction) { float distance = BehaviorVector3.DistanceXZ(source.Position, target.Position); if (effect.Shape == LogicShapeKind.Sphere) @@ -576,7 +596,7 @@ namespace XWorld.Framework.ActorBehavior { if (distance > effect.Radius + target.Radius) return false; BehaviorVector3 toTarget = (target.Position - source.Position).NormalizedXZ(); - BehaviorVector3 forward = source.Forward.NormalizedXZ(); + BehaviorVector3 forward = direction.NormalizedXZ(); float dot = Clamp(BehaviorVector3.DotXZ(forward, toTarget), -1f, 1f); float angle = (float)(Math.Acos(dot) * 180.0 / Math.PI); return angle <= effect.Angle * 0.5f; @@ -584,7 +604,7 @@ namespace XWorld.Framework.ActorBehavior if (effect.Shape == LogicShapeKind.Line) { float width = effect.Width > 0f ? effect.Width : target.Radius; - float dist = DistancePointToSegmentXZ(target.Position, source.Position, source.Position + source.Forward.NormalizedXZ() * effect.Radius); + float dist = DistancePointToSegmentXZ(target.Position, source.Position, source.Position + direction.NormalizedXZ() * effect.Radius); return dist <= width + target.Radius; } return target.ActorId == source.ActorId; @@ -637,6 +657,7 @@ namespace XWorld.Framework.ActorBehavior public float VerticalVelocity; public int HitCount; public readonly HashSet FiredTimeline = new HashSet(); + public readonly HashSet HitActorIds = new HashSet(); } private sealed class ActiveBuff diff --git a/Server/Framework.Shared.Tests/ActorBehavior/BehaviorConfigCatalogTests.cs b/Server/Framework.Shared.Tests/ActorBehavior/BehaviorConfigCatalogTests.cs index 5402a4a3..98d0a237 100644 --- a/Server/Framework.Shared.Tests/ActorBehavior/BehaviorConfigCatalogTests.cs +++ b/Server/Framework.Shared.Tests/ActorBehavior/BehaviorConfigCatalogTests.cs @@ -34,6 +34,17 @@ namespace XWorld.Framework.Tests.ActorBehavior Assert.Contains("type", ex.Message, StringComparison.OrdinalIgnoreCase); } + [Fact] + public void LoadFromJson_RejectsMismatchedVersion() + { + string mismatchedFlyObject = TestBehaviorJson.FlyObject.Replace("\"version\": 1", "\"version\": 2"); + + InvalidOperationException ex = Assert.Throws(() => + BehaviorConfigCatalog.LoadFromJson("fighter", TestBehaviorJson.Skill, mismatchedFlyObject, TestBehaviorJson.Buff)); + + Assert.Contains("version", ex.Message, StringComparison.OrdinalIgnoreCase); + } + [Fact] public void LoadFromJson_RejectsMissingReferences() { @@ -75,6 +86,24 @@ namespace XWorld.Framework.Tests.ActorBehavior Assert.Contains("width", lineEx.Message, StringComparison.OrdinalIgnoreCase); } + [Fact] + public void LoadFromJson_RejectsTimelineEntriesMissingRequiredFields() + { + Assert.Throws(() => + BehaviorConfigCatalog.LoadFromJson("fighter", TestBehaviorJson.SkillMissingAnimationName, TestBehaviorJson.FlyObject, TestBehaviorJson.Buff)); + Assert.Throws(() => + BehaviorConfigCatalog.LoadFromJson("fighter", TestBehaviorJson.SkillMissingEffectPrefab, TestBehaviorJson.FlyObject, TestBehaviorJson.Buff)); + } + + [Fact] + public void LoadFromJson_RejectsLogicOpsMissingRequiredFields() + { + InvalidOperationException ex = Assert.Throws(() => + BehaviorConfigCatalog.LoadFromJson("fighter", TestBehaviorJson.SkillMissingAttributeName, TestBehaviorJson.FlyObject, TestBehaviorJson.Buff)); + + Assert.Contains("attribute", ex.Message, StringComparison.OrdinalIgnoreCase); + } + [Fact] public void ExampleConfigFiles_LoadFromRepository() { @@ -230,6 +259,51 @@ namespace XWorld.Framework.Tests.ActorBehavior }] }"; + public const string SkillMissingAnimationName = @"{ + ""type"": ""fighter"", + ""version"": 1, + ""behaviors"": [{ + ""id"": ""bad_animation"", + ""duration"": 0.2, + ""timeline"": [ + { ""time"": 0.0, ""kind"": ""Animation"" } + ], + ""logicEffects"": [] + }] + }"; + + public const string SkillMissingEffectPrefab = @"{ + ""type"": ""fighter"", + ""version"": 1, + ""behaviors"": [{ + ""id"": ""bad_effect"", + ""duration"": 0.2, + ""timeline"": [ + { ""time"": 0.0, ""kind"": ""Effect"" } + ], + ""logicEffects"": [] + }] + }"; + + public const string SkillMissingAttributeName = @"{ + ""type"": ""fighter"", + ""version"": 1, + ""behaviors"": [{ + ""id"": ""bad_attribute"", + ""duration"": 0.2, + ""timeline"": [ + { ""time"": 0.0, ""kind"": ""LogicEffect"", ""effectId"": ""bad_hit"" } + ], + ""logicEffects"": [{ + ""id"": ""bad_hit"", + ""shape"": ""Sphere"", + ""radius"": 1.0, + ""target"": ""Enemy"", + ""effects"": [{ ""type"": ""Attribute"", ""value"": -1 }] + }] + }] + }"; + public const string SkillWithInvalidLine = @"{ ""type"": ""fighter"", ""version"": 1, diff --git a/Server/Framework.Shared.Tests/ActorBehavior/BehaviorTargetingTests.cs b/Server/Framework.Shared.Tests/ActorBehavior/BehaviorTargetingTests.cs index b0e78d4b..4e9acdff 100644 --- a/Server/Framework.Shared.Tests/ActorBehavior/BehaviorTargetingTests.cs +++ b/Server/Framework.Shared.Tests/ActorBehavior/BehaviorTargetingTests.cs @@ -32,6 +32,38 @@ namespace XWorld.Framework.Tests.ActorBehavior Assert.Contains(world.DrainEvents(), e => e.Kind == BehaviorEventKind.AttributeEffectRequested && e.TargetActorId == 3); } + + [Theory] + [InlineData("sector_test")] + [InlineData("line_test")] + public void DirectionalShapes_UseInputDirectionInsteadOfActorForward(string behaviorId) + { + BehaviorWorld world = TestTargetWorld.Create(); + + world.TryStartBehavior(1, behaviorId, BehaviorInput.FromDirection(0, 1)); + world.Tick(0.01f); + var events = world.DrainEvents(); + + Assert.Contains(events, e => + e.Kind == BehaviorEventKind.AttributeEffectRequested && e.TargetActorId == 4); + Assert.DoesNotContain(events, e => + e.Kind == BehaviorEventKind.AttributeEffectRequested && e.TargetActorId == 2); + } + + [Fact] + public void DirectionalShapes_UseTargetPositionDirectionInsteadOfActorForward() + { + BehaviorWorld world = TestTargetWorld.Create(); + + world.TryStartBehavior(1, "sector_test", BehaviorInput.FromTargetPosition(0, 0, 2)); + world.Tick(0.01f); + var events = world.DrainEvents(); + + Assert.Contains(events, e => + e.Kind == BehaviorEventKind.AttributeEffectRequested && e.TargetActorId == 4); + Assert.DoesNotContain(events, e => + e.Kind == BehaviorEventKind.AttributeEffectRequested && e.TargetActorId == 2); + } } internal static class TestTargetWorld diff --git a/Server/Framework.Shared.Tests/ActorBehavior/BehaviorWorldTests.cs b/Server/Framework.Shared.Tests/ActorBehavior/BehaviorWorldTests.cs index 3cbcc752..06433ad9 100644 --- a/Server/Framework.Shared.Tests/ActorBehavior/BehaviorWorldTests.cs +++ b/Server/Framework.Shared.Tests/ActorBehavior/BehaviorWorldTests.cs @@ -116,6 +116,23 @@ namespace XWorld.Framework.Tests.ActorBehavior Assert.Equal(0, world.ActiveBuffCount(2)); } + [Fact] + public void BuffTickLogic_AttributesEffectsToOriginalSource() + { + BehaviorWorld world = TestWorld.CreateServerWorld(); + + Assert.True(world.ApplyBuff(2, "fighter", "slow_01", 1)); + world.DrainEvents(); + world.Tick(1.0f); + var events = world.DrainEvents(); + + Assert.Contains(events, e => + e.Kind == BehaviorEventKind.AttributeEffectRequested + && e.SourceActorId == 1 + && e.TargetActorId == 2 + && e.Attribute == "moveSpeed"); + } + [Fact] public void ClientPresentation_BuffTickDoesNotEmitAuthoritativeAttributeRequests() { @@ -159,6 +176,27 @@ namespace XWorld.Framework.Tests.ActorBehavior Assert.Contains(events, e => e.Kind == BehaviorEventKind.TimelineEffect && e.FlyObjectId == "blade_wave" && e.Prefab == "Assets/Game/Art/Effect/blade_wave.prefab"); } + + [Fact] + public void Projectile_DoesNotHitSameActorMoreThanOnce() + { + string fly = TestBehaviorJson.FlyObject.Replace("\"hitLimit\": 1", "\"hitLimit\": 2"); + BehaviorConfigCatalog catalog = BehaviorConfigCatalog.LoadFromJson("fighter", TestBehaviorJson.Skill, fly, TestBehaviorJson.Buff); + var world = new BehaviorWorld(new BehaviorWorldOptions { Mode = BehaviorRunMode.ServerLogic }); + world.RegisterCatalog(catalog); + world.AddActor(new BehaviorActorState { ActorId = 1, CatalogType = "fighter", TeamId = 1, Position = new BehaviorVector3(0, 0, 0), Forward = new BehaviorVector3(1, 0, 0), Radius = 0.3f, Alive = true }); + world.AddActor(new BehaviorActorState { ActorId = 2, CatalogType = "fighter", TeamId = 2, Position = new BehaviorVector3(1, 0, 0), Forward = new BehaviorVector3(-1, 0, 0), Radius = 0.3f, Alive = true }); + + world.TryStartBehavior(1, "slash", BehaviorInput.FromDirection(1, 0)); + world.Tick(0.33f); + world.DrainEvents(); + world.Tick(0.1f); + world.Tick(0.1f); + var events = world.DrainEvents(); + + Assert.Equal(1, events.Count(e => e.Kind == BehaviorEventKind.ProjectileHit && e.TargetActorId == 2)); + Assert.Equal(1, world.ActiveProjectileCount); + } } internal static class TestWorld