From 6a3e3d298d88c6b59da4435a07fd1b8b9185ae69 Mon Sep 17 00:00:00 2001 From: ud18010 Date: Thu, 30 Jul 2026 12:24:24 +0800 Subject: [PATCH] docs: plan actor behavior system --- .../plans/2026-07-30-actor-behavior-system.md | 755 ++++++++++++++++++ 1 file changed, 755 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-30-actor-behavior-system.md diff --git a/docs/superpowers/plans/2026-07-30-actor-behavior-system.md b/docs/superpowers/plans/2026-07-30-actor-behavior-system.md new file mode 100644 index 00000000..9bf8d883 --- /dev/null +++ b/docs/superpowers/plans/2026-07-30-actor-behavior-system.md @@ -0,0 +1,755 @@ +# 角色行为(技能)系统实现计划 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 实现共享层角色行为核心,可从 JSON 加载技能、飞行物、Buff 和逻辑效果配置,并用 xUnit 覆盖第一版权威逻辑。 + +**Architecture:** 在 `Client/Assets/Framework/Shared/ActorBehavior/` 新增纯 C# 共享模块,由 `Server/Framework.Shared` 同源编译。运行时通过 `BehaviorWorld` 管理角色、主动行为、飞行物和 Buff,并输出 `BehaviorEvent` 给服务端逻辑或客户端表现层消费。 + +**Tech Stack:** C# 9.0, netstandard2.1 shared library, xUnit tests in `Server/Framework.Shared.Tests`, no UnityEngine dependency, no external JSON package. + +## Global Constraints + +- 第一版只实现共享核心、示例配置和自动化测试。 +- 不直接接入大厅角色控制器。 +- 不直接接入某个小游戏 UI。 +- 共享核心不得引用 Unity。 +- 客户端和服务端加载同一套 JSON。 +- 服务端只运行权威逻辑;客户端表现模式不执行权威属性结算。 +- JSON 解析不得引入新的 NuGet 依赖,避免破坏 Unity 和服务端同源编译。 + +--- + +## File Structure + +Create: + +- `Client/Assets/Framework/Shared/ActorBehavior/BehaviorMath.cs` + - `BehaviorVector2`、`BehaviorVector3` 和基础几何方法。 +- `Client/Assets/Framework/Shared/ActorBehavior/BehaviorTypes.cs` + - 枚举、输入、事件、角色状态、运行选项、结果类型。 +- `Client/Assets/Framework/Shared/ActorBehavior/BehaviorConfigModels.cs` + - JSON 对应的配置模型。 +- `Client/Assets/Framework/Shared/ActorBehavior/BehaviorJson.cs` + - 轻量 JSON parser 和模型转换,不依赖外部包。 +- `Client/Assets/Framework/Shared/ActorBehavior/BehaviorConfigCatalog.cs` + - 配置加载、索引和校验。 +- `Client/Assets/Framework/Shared/ActorBehavior/BehaviorWorld.cs` + - 行为、飞行物、Buff、逻辑效果运行时。 +- `Client/Assets/Resources/Config/ActorBehavior/fighter_skill.json` + - 示例技能配置。 +- `Client/Assets/Resources/Config/ActorBehavior/fighter_flyobj.json` + - 示例飞行物配置。 +- `Client/Assets/Resources/Config/ActorBehavior/fighter_buff.json` + - 示例 Buff 配置。 +- `Server/Framework.Shared.Tests/ActorBehavior/BehaviorConfigCatalogTests.cs` + - 配置解析和校验测试。 +- `Server/Framework.Shared.Tests/ActorBehavior/BehaviorWorldTests.cs` + - 行为时间线、模式过滤、飞行物和 Buff 测试。 +- `Server/Framework.Shared.Tests/ActorBehavior/BehaviorTargetingTests.cs` + - 扇形、球形、环形、直线、锁定目标测试。 + +Modify: + +- No project file changes expected. `Server/Framework.Shared/Framework.Shared.csproj` already compiles `Client/Assets/Framework/Shared/**/*.cs`. + +## Shared Interfaces + +The implementation must expose these public APIs: + +```csharp +namespace XWorld.Framework.ActorBehavior +{ + public sealed class BehaviorConfigCatalog + { + public string Type { get; } + public int Version { get; } + public IReadOnlyDictionary Behaviors { get; } + public IReadOnlyDictionary FlyObjects { get; } + public IReadOnlyDictionary Buffs { get; } + public static BehaviorConfigCatalog LoadFromJson(string type, string skillJson, string flyObjectJson, string buffJson); + } + + public sealed class BehaviorWorld + { + public BehaviorWorld(BehaviorWorldOptions options); + public void RegisterCatalog(BehaviorConfigCatalog catalog); + public void AddActor(BehaviorActorState actor); + public bool SwitchActorCatalog(int actorId, string catalogType); + public BehaviorStartResult TryStartBehavior(int actorId, string behaviorId, BehaviorInput input); + public bool StopBehavior(int actorId, BehaviorStopReason reason); + public void Tick(float deltaTime); + public IReadOnlyList DrainEvents(); + public bool HasActiveBehavior(int actorId); + public int ActiveProjectileCount { get; } + public int ActiveBuffCount(int actorId); + } +} +``` + +## Task 1: 配置模型和 JSON 加载 + +**Files:** +- Create: `Client/Assets/Framework/Shared/ActorBehavior/BehaviorMath.cs` +- Create: `Client/Assets/Framework/Shared/ActorBehavior/BehaviorTypes.cs` +- Create: `Client/Assets/Framework/Shared/ActorBehavior/BehaviorConfigModels.cs` +- Create: `Client/Assets/Framework/Shared/ActorBehavior/BehaviorJson.cs` +- Create: `Client/Assets/Framework/Shared/ActorBehavior/BehaviorConfigCatalog.cs` +- Test: `Server/Framework.Shared.Tests/ActorBehavior/BehaviorConfigCatalogTests.cs` + +**Interfaces:** +- Produces: `BehaviorConfigCatalog.LoadFromJson(string type, string skillJson, string flyObjectJson, string buffJson)` +- Produces: model classes `BehaviorSpec`, `FlyObjectSpec`, `BuffSpec`, `LogicEffectSpec`, `TimelineEntrySpec` +- Produces: enums `TimelineEntryKind`, `LogicShapeKind`, `LogicEffectOpKind`, `FlyMovementKind`, `BuffStackingKind` + +- [ ] **Step 1: Write failing config loading tests** + +```csharp +using System; +using Xunit; +using XWorld.Framework.ActorBehavior; + +namespace XWorld.Framework.Tests.ActorBehavior +{ + public class BehaviorConfigCatalogTests + { + [Fact] + public void LoadFromJson_LoadsCompleteFighterCatalog() + { + BehaviorConfigCatalog catalog = BehaviorConfigCatalog.LoadFromJson( + "fighter", + TestBehaviorJson.Skill, + TestBehaviorJson.FlyObject, + TestBehaviorJson.Buff); + + Assert.Equal("fighter", catalog.Type); + Assert.Equal(1, catalog.Version); + Assert.True(catalog.Behaviors.ContainsKey("slash")); + Assert.True(catalog.FlyObjects.ContainsKey("blade_wave")); + Assert.True(catalog.Buffs.ContainsKey("slow_01")); + Assert.Equal(4, catalog.Behaviors["slash"].Timeline.Count); + } + + [Fact] + public void LoadFromJson_RejectsMismatchedType() + { + string mismatchedBuff = TestBehaviorJson.Buff.Replace("\"type\": \"fighter\"", "\"type\": \"mage\""); + + InvalidOperationException ex = Assert.Throws(() => + BehaviorConfigCatalog.LoadFromJson("fighter", TestBehaviorJson.Skill, TestBehaviorJson.FlyObject, mismatchedBuff)); + + Assert.Contains("type", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void LoadFromJson_RejectsMissingReferences() + { + string badSkill = TestBehaviorJson.Skill.Replace("\"flyObjectId\": \"blade_wave\"", "\"flyObjectId\": \"missing_wave\""); + + InvalidOperationException ex = Assert.Throws(() => + BehaviorConfigCatalog.LoadFromJson("fighter", badSkill, TestBehaviorJson.FlyObject, TestBehaviorJson.Buff)); + + Assert.Contains("missing_wave", ex.Message); + } + } +} +``` + +- [ ] **Step 2: Add test fixture JSON** + +Add a `TestBehaviorJson` helper in the same test file: + +```csharp +internal static class TestBehaviorJson +{ + public const string Skill = @"{ + ""type"": ""fighter"", + ""version"": 1, + ""behaviors"": [{ + ""id"": ""slash"", + ""duration"": 0.8, + ""canBeInterrupted"": true, + ""input"": ""DirectionOrTarget"", + ""timeline"": [ + { ""time"": 0.0, ""kind"": ""Animation"", ""animation"": ""slash_01"", ""duration"": 0.8 }, + { ""time"": 0.12, ""kind"": ""Effect"", ""prefab"": ""Assets/Game/Art/Effect/slash.prefab"", ""duration"": 0.5 }, + { ""time"": 0.25, ""kind"": ""LogicEffect"", ""effectId"": ""slash_hit"" }, + { ""time"": 0.32, ""kind"": ""Projectile"", ""flyObjectId"": ""blade_wave"" } + ], + ""logicEffects"": [{ + ""id"": ""slash_hit"", + ""shape"": ""Sector"", + ""radius"": 2.4, + ""angle"": 90, + ""target"": ""Enemy"", + ""effects"": [{ ""type"": ""Attribute"", ""attribute"": ""hp"", ""value"": -30 }] + }] + }] + }"; + + public const string FlyObject = @"{ + ""type"": ""fighter"", + ""version"": 1, + ""flyObjects"": [{ + ""id"": ""blade_wave"", + ""duration"": 1.5, + ""movement"": { ""kind"": ""Line"", ""speed"": 6.0 }, + ""collision"": { ""shape"": ""Sphere"", ""radius"": 0.4, ""hitLimit"": 1 }, + ""timeline"": [ + { ""time"": 0.0, ""kind"": ""Effect"", ""prefab"": ""Assets/Game/Art/Effect/blade_wave.prefab"", ""duration"": 1.5 }, + { ""time"": 0.0, ""kind"": ""LogicEffect"", ""effectId"": ""blade_wave_hit"", ""trigger"": ""OnCollision"" } + ], + ""logicEffects"": [{ + ""id"": ""blade_wave_hit"", + ""shape"": ""Locked"", + ""target"": ""HitActor"", + ""effects"": [ + { ""type"": ""Attribute"", ""attribute"": ""hp"", ""value"": -20 }, + { ""type"": ""Buff"", ""buffId"": ""slow_01"" } + ] + }] + }] + }"; + + public const string Buff = @"{ + ""type"": ""fighter"", + ""version"": 1, + ""buffs"": [{ + ""id"": ""slow_01"", + ""duration"": 3.0, + ""tickInterval"": 1.0, + ""repeatCount"": 3, + ""stacking"": ""RefreshDuration"", + ""logicEffects"": [{ + ""id"": ""slow_tick"", + ""shape"": ""Locked"", + ""target"": ""Owner"", + ""effects"": [{ ""type"": ""Attribute"", ""attribute"": ""moveSpeed"", ""value"": -0.2 }] + }] + }] + }"; +} +``` + +- [ ] **Step 3: Run tests to verify red** + +Run: + +```powershell +dotnet test Server\Framework.Shared.Tests\Framework.Shared.Tests.csproj --filter BehaviorConfigCatalogTests +``` + +Expected: FAIL because `XWorld.Framework.ActorBehavior` types do not exist. + +- [ ] **Step 4: Implement minimal config models and parser** + +Create the files listed above. Implement: + +- A recursive JSON parser supporting object, array, string, number, boolean, and null. +- Typed conversion helpers for required strings and optional numbers. +- Public config model classes with public fields/properties. +- Catalog validation for type mismatch, missing references, duplicates, invalid duration, and invalid shapes. + +- [ ] **Step 5: Run tests to verify green** + +Run: + +```powershell +dotnet test Server\Framework.Shared.Tests\Framework.Shared.Tests.csproj --filter BehaviorConfigCatalogTests +``` + +Expected: PASS. + +## Task 2: 行为状态机和时间线事件 + +**Files:** +- Modify: `Client/Assets/Framework/Shared/ActorBehavior/BehaviorWorld.cs` +- Test: `Server/Framework.Shared.Tests/ActorBehavior/BehaviorWorldTests.cs` + +**Interfaces:** +- Consumes: `BehaviorConfigCatalog` +- Produces: `BehaviorWorld.TryStartBehavior`, `StopBehavior`, `Tick`, `DrainEvents`, `HasActiveBehavior` +- Produces: `BehaviorEventKind.BehaviorStarted`, `BehaviorStopped`, `TimelineAnimation`, `TimelineEffect`, `LogicEffectApplied`, `AttributeEffectRequested` + +- [ ] **Step 1: Write failing behavior runtime tests** + +```csharp +using System.Linq; +using Xunit; +using XWorld.Framework.ActorBehavior; + +namespace XWorld.Framework.Tests.ActorBehavior +{ + public class BehaviorWorldTests + { + [Fact] + public void TryStartBehavior_AllowsOnlyOneActiveBehavior() + { + BehaviorWorld world = TestWorld.CreateServerWorld(); + Assert.True(world.TryStartBehavior(1, "slash", BehaviorInput.FromDirection(1, 0)).Success); + + BehaviorStartResult second = world.TryStartBehavior(1, "slash", BehaviorInput.FromDirection(1, 0)); + + Assert.False(second.Success); + Assert.True(world.HasActiveBehavior(1)); + } + + [Fact] + public void StopBehavior_AllowsInterruptibleBehaviorToSwitch() + { + BehaviorWorld world = TestWorld.CreateServerWorld(); + world.TryStartBehavior(1, "slash", BehaviorInput.FromDirection(1, 0)); + + Assert.True(world.StopBehavior(1, BehaviorStopReason.Stunned)); + Assert.True(world.TryStartBehavior(1, "slash", BehaviorInput.FromDirection(1, 0)).Success); + + Assert.Contains(world.DrainEvents(), e => e.Kind == BehaviorEventKind.BehaviorStopped); + } + + [Fact] + public void Tick_TriggersTimelineEventsAtConfiguredTimes() + { + BehaviorWorld world = TestWorld.CreatePresentationWorld(); + world.TryStartBehavior(1, "slash", BehaviorInput.FromDirection(1, 0)); + + world.Tick(0.26f); + var events = world.DrainEvents(); + + Assert.Contains(events, e => e.Kind == BehaviorEventKind.TimelineAnimation && e.Animation == "slash_01"); + Assert.Contains(events, e => e.Kind == BehaviorEventKind.TimelineEffect && e.Prefab == "Assets/Game/Art/Effect/slash.prefab"); + } + + [Fact] + public void ServerLogic_SkipsPresentationEventsAndAppliesLogic() + { + BehaviorWorld world = TestWorld.CreateServerWorld(); + world.TryStartBehavior(1, "slash", BehaviorInput.FromDirection(1, 0)); + + world.Tick(0.26f); + var events = world.DrainEvents(); + + Assert.DoesNotContain(events, e => e.Kind == BehaviorEventKind.TimelineAnimation); + Assert.DoesNotContain(events, e => e.Kind == BehaviorEventKind.TimelineEffect); + Assert.Contains(events, e => e.Kind == BehaviorEventKind.AttributeEffectRequested && e.TargetActorId == 2 && e.Attribute == "hp"); + } + } +} +``` + +- [ ] **Step 2: Add shared test world helper** + +Add to `BehaviorWorldTests.cs`: + +```csharp +internal static class TestWorld +{ + public static BehaviorWorld CreateServerWorld() + { + return Create(BehaviorRunMode.ServerLogic); + } + + public static BehaviorWorld CreatePresentationWorld() + { + return Create(BehaviorRunMode.ClientPresentation); + } + + private static BehaviorWorld Create(BehaviorRunMode mode) + { + BehaviorConfigCatalog catalog = BehaviorConfigCatalog.LoadFromJson( + "fighter", + TestBehaviorJson.Skill, + TestBehaviorJson.FlyObject, + TestBehaviorJson.Buff); + var world = new BehaviorWorld(new BehaviorWorldOptions { Mode = mode }); + 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 }); + return world; + } +} +``` + +- [ ] **Step 3: Run tests to verify red** + +Run: + +```powershell +dotnet test Server\Framework.Shared.Tests\Framework.Shared.Tests.csproj --filter BehaviorWorldTests +``` + +Expected: FAIL because runtime behavior is not implemented. + +- [ ] **Step 4: Implement behavior runtime** + +Implement: + +- Actor dictionary. +- Catalog registration. +- Active behavior state with elapsed time and fired timeline indices. +- `TryStartBehavior` returning `BehaviorStartResult`. +- Immediate firing of timeline entries at `time <= 0`. +- `Tick` firing timeline entries when `previousElapsed < entry.time <= currentElapsed`. +- Auto-stop when elapsed reaches behavior duration. +- Run-mode filtering: presentation events only in `ClientPresentation`, logic application only in `ServerLogic`. + +- [ ] **Step 5: Run tests to verify green** + +Run: + +```powershell +dotnet test Server\Framework.Shared.Tests\Framework.Shared.Tests.csproj --filter BehaviorWorldTests +``` + +Expected: PASS for behavior state and timeline tests. + +## Task 3: 目标选择和逻辑效果 + +**Files:** +- Modify: `Client/Assets/Framework/Shared/ActorBehavior/BehaviorWorld.cs` +- Modify: `Client/Assets/Framework/Shared/ActorBehavior/BehaviorMath.cs` +- Test: `Server/Framework.Shared.Tests/ActorBehavior/BehaviorTargetingTests.cs` + +**Interfaces:** +- Consumes: `LogicEffectSpec` +- Produces: target selection for `Sector`, `Sphere`, `Ring`, `Line`, `Locked` + +- [ ] **Step 1: Write failing targeting tests** + +```csharp +using System.Linq; +using Xunit; +using XWorld.Framework.ActorBehavior; + +namespace XWorld.Framework.Tests.ActorBehavior +{ + public class BehaviorTargetingTests + { + [Theory] + [InlineData("sector_test", 2)] + [InlineData("sphere_test", 2)] + [InlineData("ring_test", 3)] + [InlineData("line_test", 2)] + public void LogicEffectShapes_SelectExpectedTargets(string behaviorId, int expectedTarget) + { + BehaviorWorld world = TestTargetWorld.Create(); + + world.TryStartBehavior(1, behaviorId, BehaviorInput.FromDirection(1, 0)); + world.Tick(0.01f); + + Assert.Contains(world.DrainEvents(), e => + e.Kind == BehaviorEventKind.AttributeEffectRequested && e.TargetActorId == expectedTarget); + } + + [Fact] + public void LockedEffect_UsesTargetActorInput() + { + BehaviorWorld world = TestTargetWorld.Create(); + + world.TryStartBehavior(1, "locked_test", BehaviorInput.FromTargetActor(3)); + world.Tick(0.01f); + + Assert.Contains(world.DrainEvents(), e => + e.Kind == BehaviorEventKind.AttributeEffectRequested && e.TargetActorId == 3); + } + } +} +``` + +- [ ] **Step 2: Add targeting fixture JSON helper** + +Create `TestTargetWorld` in the same file. It should build a catalog with five behaviors, each firing one `LogicEffect` at `time: 0`, and actors at: + +```csharp +actor 1: position (0,0,0), forward (1,0,0), team 1 +actor 2: position (1,0,0), team 2 +actor 3: position (3,0,0), team 2 +actor 4: position (0,0,2), team 2 +``` + +Use shape parameters: + +- `sector_test`: `Sector`, radius `2`, angle `90`, target `Enemy`. +- `sphere_test`: `Sphere`, radius `1.5`, target `Enemy`. +- `ring_test`: `Ring`, minRadius `2`, radius `3.5`, target `Enemy`. +- `line_test`: `Line`, radius `2`, width `0.5`, target `Enemy`. +- `locked_test`: `Locked`, target `HitActor`. + +- [ ] **Step 3: Run tests to verify red** + +Run: + +```powershell +dotnet test Server\Framework.Shared.Tests\Framework.Shared.Tests.csproj --filter BehaviorTargetingTests +``` + +Expected: FAIL until shape selection is implemented. + +- [ ] **Step 4: Implement shape selection** + +Implement geometry in XZ plane: + +- `Sphere`: distance from origin to actor center is within `radius + actor.Radius`. +- `Ring`: distance is between `minRadius` and `radius + actor.Radius`. +- `Sector`: within radius and absolute angle from forward is within `angle / 2`. +- `Line`: distance from actor center to segment `[origin, origin + forward * radius]` is within `width + actor.Radius`. +- `Locked`: choose input target actor, owner, or hit actor according to target filter. + +- [ ] **Step 5: Run tests to verify green** + +Run: + +```powershell +dotnet test Server\Framework.Shared.Tests\Framework.Shared.Tests.csproj --filter BehaviorTargetingTests +``` + +Expected: PASS. + +## Task 4: 飞行物运行时和碰撞 + +**Files:** +- Modify: `Client/Assets/Framework/Shared/ActorBehavior/BehaviorWorld.cs` +- Test: `Server/Framework.Shared.Tests/ActorBehavior/BehaviorWorldTests.cs` + +**Interfaces:** +- Produces: `ActiveProjectileCount` +- Produces: `BehaviorEventKind.ProjectileSpawned`, `ProjectileMoved`, `ProjectileHit` + +- [ ] **Step 1: Write failing projectile tests** + +Add tests to `BehaviorWorldTests.cs`: + +```csharp +[Fact] +public void Projectile_SpawnsMovesHitsAndExpiresAtHitLimit() +{ + BehaviorWorld world = TestWorld.CreateServerWorld(); + + world.TryStartBehavior(1, "slash", BehaviorInput.FromDirection(1, 0)); + world.Tick(0.33f); + Assert.True(world.ActiveProjectileCount > 0); + + world.Tick(0.1f); + var events = world.DrainEvents(); + + Assert.Contains(events, e => e.Kind == BehaviorEventKind.ProjectileSpawned); + Assert.Contains(events, e => e.Kind == BehaviorEventKind.ProjectileMoved); + Assert.Contains(events, e => e.Kind == BehaviorEventKind.ProjectileHit && e.TargetActorId == 2); + Assert.Equal(0, world.ActiveProjectileCount); +} + +[Theory] +[InlineData("Line")] +[InlineData("Parabola")] +[InlineData("Homing")] +public void ProjectileMovementModes_UpdatePosition(string movementKind) +{ + string fly = TestBehaviorJson.FlyObject.Replace(@"""kind"": ""Line""", @"""kind"": """ + movementKind + @""""); + BehaviorConfigCatalog catalog = BehaviorConfigCatalog.LoadFromJson("fighter", TestBehaviorJson.Skill, fly, TestBehaviorJson.Buff); + var world = new BehaviorWorld(new BehaviorWorldOptions { Mode = BehaviorRunMode.ClientPresentation }); + 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(4, 0, 0), Forward = new BehaviorVector3(-1, 0, 0), Radius = 0.3f, Alive = true }); + + world.TryStartBehavior(1, "slash", BehaviorInput.FromTargetActor(2)); + world.Tick(0.33f); + world.DrainEvents(); + world.Tick(0.1f); + + Assert.Contains(world.DrainEvents(), e => e.Kind == BehaviorEventKind.ProjectileMoved && e.Position.X > 0); +} +``` + +- [ ] **Step 2: Run projectile tests to verify red** + +Run: + +```powershell +dotnet test Server\Framework.Shared.Tests\Framework.Shared.Tests.csproj --filter "Projectile" +``` + +Expected: FAIL until projectile runtime is implemented. + +- [ ] **Step 3: Implement projectile runtime** + +Implement: + +- Projectile spawn from timeline entries with `kind: Projectile`. +- Position starts at owner position. +- Direction comes from `BehaviorInput.Direction`, target actor direction, or actor forward. +- `Line`: `position += direction * speed * dt`. +- `Parabola`: horizontal direction with speed, vertical velocity affected by gravity; keep XZ deterministic. +- `Homing`: direction recalculated toward target actor each tick. +- Sphere collision against alive enemy actors. +- On collision emit `ProjectileHit`, run `OnCollision` logic effect, increment hit count, expire at `hitLimit`. + +- [ ] **Step 4: Run projectile tests to verify green** + +Run: + +```powershell +dotnet test Server\Framework.Shared.Tests\Framework.Shared.Tests.csproj --filter "Projectile" +``` + +Expected: PASS. + +## Task 5: Buff 运行时 + +**Files:** +- Modify: `Client/Assets/Framework/Shared/ActorBehavior/BehaviorWorld.cs` +- Test: `Server/Framework.Shared.Tests/ActorBehavior/BehaviorWorldTests.cs` + +**Interfaces:** +- Produces: `ActiveBuffCount(int actorId)` +- Produces: `BehaviorEventKind.BuffAdded`, `BuffTicked`, `BuffRemoved` + +- [ ] **Step 1: Write failing buff test** + +Add to `BehaviorWorldTests.cs`: + +```csharp +[Fact] +public void Buff_TicksByIntervalRepeatCountAndExpires() +{ + BehaviorWorld world = TestWorld.CreateServerWorld(); + + Assert.True(world.ApplyBuff(2, "fighter", "slow_01", 1)); + Assert.Equal(1, world.ActiveBuffCount(2)); + + world.Tick(1.0f); + world.Tick(1.0f); + world.Tick(1.0f); + world.Tick(0.1f); + var events = world.DrainEvents(); + + Assert.Contains(events, e => e.Kind == BehaviorEventKind.BuffAdded && e.TargetActorId == 2); + Assert.Equal(3, events.Count(e => e.Kind == BehaviorEventKind.BuffTicked && e.TargetActorId == 2)); + Assert.Contains(events, e => e.Kind == BehaviorEventKind.BuffRemoved && e.TargetActorId == 2); + Assert.Equal(0, world.ActiveBuffCount(2)); +} +``` + +Use the public `ApplyBuff(int targetActorId, string catalogType, string buffId, int sourceActorId)` API. Do not add test-only production methods. + +- [ ] **Step 2: Run buff test to verify red** + +Run: + +```powershell +dotnet test Server\Framework.Shared.Tests\Framework.Shared.Tests.csproj --filter "Buff" +``` + +Expected: FAIL until buff runtime is implemented. + +- [ ] **Step 3: Implement buff runtime** + +Implement: + +- Public `ApplyBuff(int targetActorId, string catalogType, string buffId, int sourceActorId)`. +- Stacking modes `RefreshDuration`, `IgnoreIfExists`, `StackIndependent`. +- Tick elapsed duration. +- Fire `BuffTicked` when elapsed crosses each interval. +- Respect `repeatCount`. +- Fire `BuffRemoved` when duration expires or repeat count is reached. +- Logic operations of type `Buff` should call `ApplyBuff`. + +- [ ] **Step 4: Run buff tests to verify green** + +Run: + +```powershell +dotnet test Server\Framework.Shared.Tests\Framework.Shared.Tests.csproj --filter "Buff" +``` + +Expected: PASS. + +## Task 6: 示例配置、全量验证和 cleanup + +**Files:** +- Create: `Client/Assets/Resources/Config/ActorBehavior/fighter_skill.json` +- Create: `Client/Assets/Resources/Config/ActorBehavior/fighter_flyobj.json` +- Create: `Client/Assets/Resources/Config/ActorBehavior/fighter_buff.json` +- Modify: all new actor behavior files as needed after refactor +- Test: `Server/Framework.Shared.Tests/ActorBehavior/BehaviorConfigCatalogTests.cs` + +**Interfaces:** +- Consumes: all previous public APIs. +- Produces: repository example JSON matching the spec. + +- [ ] **Step 1: Write failing example config test** + +Add to `BehaviorConfigCatalogTests.cs`: + +```csharp +[Fact] +public void ExampleConfigFiles_LoadFromRepository() +{ + string root = FindRepoRoot(); + string dir = System.IO.Path.Combine(root, "Client", "Assets", "Resources", "Config", "ActorBehavior"); + + BehaviorConfigCatalog catalog = BehaviorConfigCatalog.LoadFromJson( + "fighter", + System.IO.File.ReadAllText(System.IO.Path.Combine(dir, "fighter_skill.json")), + System.IO.File.ReadAllText(System.IO.Path.Combine(dir, "fighter_flyobj.json")), + System.IO.File.ReadAllText(System.IO.Path.Combine(dir, "fighter_buff.json"))); + + Assert.True(catalog.Behaviors.ContainsKey("slash")); +} + +private static string FindRepoRoot() +{ + string dir = AppContext.BaseDirectory; + while (dir != null) + { + if (System.IO.Directory.Exists(System.IO.Path.Combine(dir, ".git"))) + { + return dir; + } + dir = System.IO.Directory.GetParent(dir)?.FullName; + } + throw new InvalidOperationException("repo root not found"); +} +``` + +- [ ] **Step 2: Run example test to verify red** + +Run: + +```powershell +dotnet test Server\Framework.Shared.Tests\Framework.Shared.Tests.csproj --filter ExampleConfigFiles_LoadFromRepository +``` + +Expected: FAIL until example files exist. + +- [ ] **Step 3: Add example JSON files** + +Create the three example files using the JSON from `TestBehaviorJson`. + +- [ ] **Step 4: Run full shared tests** + +Run: + +```powershell +dotnet test Server\Framework.Shared.Tests\Framework.Shared.Tests.csproj +``` + +Expected: PASS. + +- [ ] **Step 5: Run server solution build** + +Run: + +```powershell +dotnet build Server\Server.sln +``` + +Expected: PASS. + +- [ ] **Step 6: Review git diff** + +Run: + +```powershell +git diff -- Client/Assets/Framework/Shared/ActorBehavior Server/Framework.Shared.Tests/ActorBehavior Client/Assets/Resources/Config/ActorBehavior +``` + +Expected: only actor behavior core, tests, and example config files changed.