docs: plan actor behavior system

This commit is contained in:
ud18010
2026-07-30 12:24:24 +08:00
parent 2a745f5e85
commit 6a3e3d298d
@@ -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<string, BehaviorSpec> Behaviors { get; }
public IReadOnlyDictionary<string, FlyObjectSpec> FlyObjects { get; }
public IReadOnlyDictionary<string, BuffSpec> 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<BehaviorEvent> 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<InvalidOperationException>(() =>
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<InvalidOperationException>(() =>
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.