diff --git a/Client/Assets/Framework/Shared/ActorBehavior/BehaviorConfigCatalog.cs b/Client/Assets/Framework/Shared/ActorBehavior/BehaviorConfigCatalog.cs index 4711dcb8..7860e55c 100644 --- a/Client/Assets/Framework/Shared/ActorBehavior/BehaviorConfigCatalog.cs +++ b/Client/Assets/Framework/Shared/ActorBehavior/BehaviorConfigCatalog.cs @@ -204,7 +204,11 @@ namespace XWorld.Framework.ActorBehavior foreach (FlyObjectSpec fly in _flyObjects.Values) { ValidateDuration(fly.Duration, "flyObject " + fly.Id); + if (fly.Movement.Speed < 0f) + throw new InvalidOperationException("Invalid speed for flyObject " + fly.Id); ValidateDuration(fly.Collision.Radius, "flyObject collision " + fly.Id); + if (fly.Collision.HitLimit < 0) + throw new InvalidOperationException("Invalid hitLimit for flyObject " + fly.Id); ValidateTimeline(fly.Timeline, fly.LogicEffects, "flyObject " + fly.Id); ValidateLogicEffects(fly.LogicEffects, "flyObject " + fly.Id); } @@ -242,8 +246,12 @@ namespace XWorld.Framework.ActorBehavior AddUnique(ids, effect.Id, "logicEffect"); if (effect.Shape != LogicShapeKind.Locked && effect.Radius < 0f) throw new InvalidOperationException("Invalid radius for " + effect.Id + " in " + owner); + if (effect.Shape == LogicShapeKind.Ring && (effect.MinRadius < 0f || effect.MinRadius > effect.Radius)) + throw new InvalidOperationException("Invalid minRadius for " + effect.Id + " in " + owner); if (effect.Shape == LogicShapeKind.Sector && (effect.Angle <= 0f || effect.Angle > 360f)) throw new InvalidOperationException("Invalid sector angle for " + effect.Id + " in " + owner); + if (effect.Shape == LogicShapeKind.Line && effect.Width < 0f) + 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)) diff --git a/Client/Assets/Framework/Shared/ActorBehavior/BehaviorWorld.cs b/Client/Assets/Framework/Shared/ActorBehavior/BehaviorWorld.cs index 15694be1..9a79c9c6 100644 --- a/Client/Assets/Framework/Shared/ActorBehavior/BehaviorWorld.cs +++ b/Client/Assets/Framework/Shared/ActorBehavior/BehaviorWorld.cs @@ -236,6 +236,13 @@ namespace XWorld.Framework.ActorBehavior return; } + if (entry.Kind == TimelineEntryKind.Buff) + { + int targetActorId = active.Input.Kind == BehaviorInputKind.TargetActor ? active.Input.TargetActorId : active.ActorId; + ApplyBuff(targetActorId, active.Catalog.Type, entry.BuffId, active.ActorId); + return; + } + if (entry.Kind == TimelineEntryKind.Projectile) { SpawnProjectile(active.ActorId, active.Catalog, entry.FlyObjectId, active.Input); @@ -308,8 +315,11 @@ namespace XWorld.Framework.ActorBehavior TargetActorId = buff.TargetActorId, BuffId = buff.Spec.Id }); - 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); + 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); + } buff.NextTickTime += buff.Spec.TickInterval; } @@ -364,6 +374,7 @@ namespace XWorld.Framework.ActorBehavior FlyObjectId = spec.Id, Position = projectile.Position }); + FireProjectileTimeline(projectile, -0.00001f, 0f); } private BehaviorVector3 ResolveProjectileDirection(BehaviorActorState source, BehaviorInput input, int targetActorId) @@ -382,6 +393,7 @@ namespace XWorld.Framework.ActorBehavior var expired = new HashSet(); foreach (ActiveProjectile projectile in _projectiles) { + float previousAge = projectile.Age; projectile.Age += deltaTime; MoveProjectile(projectile, deltaTime); _events.Add(new BehaviorEvent @@ -392,6 +404,7 @@ namespace XWorld.Framework.ActorBehavior FlyObjectId = projectile.Spec.Id, Position = projectile.Position }); + FireProjectileTimeline(projectile, previousAge, projectile.Age); if (_options.Mode == BehaviorRunMode.ServerLogic) CheckProjectileCollision(projectile, expired); @@ -458,6 +471,54 @@ namespace XWorld.Framework.ActorBehavior } } + private void FireProjectileTimeline(ActiveProjectile projectile, float previousElapsed, float currentElapsed) + { + List timeline = projectile.Spec.Timeline; + for (int i = 0; i < timeline.Count; i++) + { + if (projectile.FiredTimeline.Contains(i)) continue; + TimelineEntrySpec entry = timeline[i]; + if (entry.Trigger != TimelineTriggerKind.Time) continue; + if (entry.Time > currentElapsed || entry.Time <= previousElapsed) continue; + projectile.FiredTimeline.Add(i); + FireProjectileTimelineEntry(projectile, entry); + } + } + + private void FireProjectileTimelineEntry(ActiveProjectile projectile, TimelineEntrySpec entry) + { + if (entry.Kind == TimelineEntryKind.Effect) + { + if (_options.Mode == BehaviorRunMode.ClientPresentation || _options.Mode == BehaviorRunMode.ClientPredict) + { + _events.Add(new BehaviorEvent + { + Kind = BehaviorEventKind.TimelineEffect, + ActorId = projectile.SourceActorId, + SourceActorId = projectile.SourceActorId, + FlyObjectId = projectile.Spec.Id, + Prefab = entry.Prefab, + Position = projectile.Position + }); + } + return; + } + + if (entry.Kind == TimelineEntryKind.LogicEffect && _options.Mode == BehaviorRunMode.ServerLogic) + { + LogicEffectSpec effect = FindLogicEffect(projectile.Spec.LogicEffects, entry.EffectId); + if (effect != null) + ApplyLogicEffect(projectile.SourceActorId, projectile.Catalog, effect, BehaviorInput.FromTargetActor(projectile.TargetActorId), projectile.TargetActorId); + return; + } + + if (entry.Kind == TimelineEntryKind.Buff) + { + int targetActorId = projectile.TargetActorId != 0 ? projectile.TargetActorId : projectile.SourceActorId; + ApplyBuff(targetActorId, projectile.Catalog.Type, entry.BuffId, projectile.SourceActorId); + } + } + private void FireProjectileCollisionLogic(ActiveProjectile projectile, int hitActorId) { List timeline = projectile.Spec.Timeline; @@ -575,6 +636,7 @@ namespace XWorld.Framework.ActorBehavior public float Age; public float VerticalVelocity; public int HitCount; + public readonly HashSet FiredTimeline = 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 3a279805..5402a4a3 100644 --- a/Server/Framework.Shared.Tests/ActorBehavior/BehaviorConfigCatalogTests.cs +++ b/Server/Framework.Shared.Tests/ActorBehavior/BehaviorConfigCatalogTests.cs @@ -45,6 +45,36 @@ namespace XWorld.Framework.Tests.ActorBehavior Assert.Contains("missing_wave", ex.Message); } + [Fact] + public void LoadFromJson_RejectsInvalidFlyObjectMovementAndCollision() + { + string badSpeedFlyObject = TestBehaviorJson.FlyObject.Replace("\"speed\": 6.0", "\"speed\": -6.0"); + string badHitLimitFlyObject = TestBehaviorJson.FlyObject.Replace("\"hitLimit\": 1", "\"hitLimit\": -1"); + + InvalidOperationException speedEx = Assert.Throws(() => + BehaviorConfigCatalog.LoadFromJson("fighter", TestBehaviorJson.Skill, badSpeedFlyObject, TestBehaviorJson.Buff)); + InvalidOperationException hitLimitEx = Assert.Throws(() => + BehaviorConfigCatalog.LoadFromJson("fighter", TestBehaviorJson.Skill, badHitLimitFlyObject, TestBehaviorJson.Buff)); + + Assert.Contains("speed", speedEx.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("hitLimit", hitLimitEx.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void LoadFromJson_RejectsInvalidRingAndLineShapeParameters() + { + InvalidOperationException ringEx = Assert.Throws(() => + BehaviorConfigCatalog.LoadFromJson("fighter", TestBehaviorJson.SkillWithInvalidRing, TestBehaviorJson.FlyObject, TestBehaviorJson.Buff)); + InvalidOperationException negativeRingEx = Assert.Throws(() => + BehaviorConfigCatalog.LoadFromJson("fighter", TestBehaviorJson.SkillWithNegativeRingMinRadius, TestBehaviorJson.FlyObject, TestBehaviorJson.Buff)); + InvalidOperationException lineEx = Assert.Throws(() => + BehaviorConfigCatalog.LoadFromJson("fighter", TestBehaviorJson.SkillWithInvalidLine, TestBehaviorJson.FlyObject, TestBehaviorJson.Buff)); + + Assert.Contains("minRadius", ringEx.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("minRadius", negativeRingEx.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("width", lineEx.Message, StringComparison.OrdinalIgnoreCase); + } + [Fact] public void ExampleConfigFiles_LoadFromRepository() { @@ -144,5 +174,80 @@ namespace XWorld.Framework.Tests.ActorBehavior }] }] }"; + + public const string SkillWithBuffTimeline = @"{ + ""type"": ""fighter"", + ""version"": 1, + ""behaviors"": [{ + ""id"": ""slow_cast"", + ""duration"": 0.2, + ""canBeInterrupted"": true, + ""input"": ""TargetActor"", + ""timeline"": [ + { ""time"": 0.0, ""kind"": ""Buff"", ""buffId"": ""slow_01"" } + ], + ""logicEffects"": [] + }] + }"; + + public const string SkillWithInvalidRing = @"{ + ""type"": ""fighter"", + ""version"": 1, + ""behaviors"": [{ + ""id"": ""bad_ring"", + ""duration"": 0.2, + ""timeline"": [ + { ""time"": 0.0, ""kind"": ""LogicEffect"", ""effectId"": ""ring_hit"" } + ], + ""logicEffects"": [{ + ""id"": ""ring_hit"", + ""shape"": ""Ring"", + ""radius"": 1.0, + ""minRadius"": 2.0, + ""target"": ""Enemy"", + ""effects"": [{ ""type"": ""Attribute"", ""attribute"": ""hp"", ""value"": -1 }] + }] + }] + }"; + + public const string SkillWithNegativeRingMinRadius = @"{ + ""type"": ""fighter"", + ""version"": 1, + ""behaviors"": [{ + ""id"": ""bad_negative_ring"", + ""duration"": 0.2, + ""timeline"": [ + { ""time"": 0.0, ""kind"": ""LogicEffect"", ""effectId"": ""ring_hit"" } + ], + ""logicEffects"": [{ + ""id"": ""ring_hit"", + ""shape"": ""Ring"", + ""radius"": 1.0, + ""minRadius"": -0.1, + ""target"": ""Enemy"", + ""effects"": [{ ""type"": ""Attribute"", ""attribute"": ""hp"", ""value"": -1 }] + }] + }] + }"; + + public const string SkillWithInvalidLine = @"{ + ""type"": ""fighter"", + ""version"": 1, + ""behaviors"": [{ + ""id"": ""bad_line"", + ""duration"": 0.2, + ""timeline"": [ + { ""time"": 0.0, ""kind"": ""LogicEffect"", ""effectId"": ""line_hit"" } + ], + ""logicEffects"": [{ + ""id"": ""line_hit"", + ""shape"": ""Line"", + ""radius"": 1.0, + ""width"": -0.2, + ""target"": ""Enemy"", + ""effects"": [{ ""type"": ""Attribute"", ""attribute"": ""hp"", ""value"": -1 }] + }] + }] + }"; } } diff --git a/Server/Framework.Shared.Tests/ActorBehavior/BehaviorWorldTests.cs b/Server/Framework.Shared.Tests/ActorBehavior/BehaviorWorldTests.cs index dc9677e2..3cbcc752 100644 --- a/Server/Framework.Shared.Tests/ActorBehavior/BehaviorWorldTests.cs +++ b/Server/Framework.Shared.Tests/ActorBehavior/BehaviorWorldTests.cs @@ -115,6 +115,50 @@ namespace XWorld.Framework.Tests.ActorBehavior Assert.Contains(events, e => e.Kind == BehaviorEventKind.BuffRemoved && e.TargetActorId == 2); Assert.Equal(0, world.ActiveBuffCount(2)); } + + [Fact] + public void ClientPresentation_BuffTickDoesNotEmitAuthoritativeAttributeRequests() + { + BehaviorWorld world = TestWorld.CreatePresentationWorld(); + + 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.BuffTicked && e.TargetActorId == 2); + Assert.DoesNotContain(events, e => e.Kind == BehaviorEventKind.AttributeEffectRequested); + Assert.DoesNotContain(events, e => e.Kind == BehaviorEventKind.LogicEffectApplied); + } + + [Fact] + public void BehaviorTimelineBuff_AppliesConfiguredBuff() + { + BehaviorConfigCatalog catalog = BehaviorConfigCatalog.LoadFromJson("fighter", TestBehaviorJson.SkillWithBuffTimeline, TestBehaviorJson.FlyObject, 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, "slow_cast", BehaviorInput.FromTargetActor(2)); + world.Tick(0.01f); + var events = world.DrainEvents(); + + Assert.Contains(events, e => e.Kind == BehaviorEventKind.BuffAdded && e.TargetActorId == 2 && e.BuffId == "slow_01"); + Assert.Equal(1, world.ActiveBuffCount(2)); + } + + [Fact] + public void ProjectileTimelineEffect_FiresInClientPresentation() + { + BehaviorWorld world = TestWorld.CreatePresentationWorld(); + + world.TryStartBehavior(1, "slash", BehaviorInput.FromDirection(1, 0)); + world.Tick(0.33f); + var events = world.DrainEvents(); + + Assert.Contains(events, e => e.Kind == BehaviorEventKind.TimelineEffect && e.FlyObjectId == "blade_wave" && e.Prefab == "Assets/Game/Art/Effect/blade_wave.prefab"); + } } internal static class TestWorld