Buff 系统架构P.Cell · 命名空间 Game.Buff

11 文件 / 3359 行 56 用例全绿 主线程 only 依据 HEAD 62ef744 + 工作区 2026-9-13

总总览与图例

通用 Buff 模块:门面负责装配与主线程纪律,内核负责状态机、索引、池化与事件冲刷;一切宿主差异都经 IBuffHost.TryGetCapability<T>() 协商——模块本身不认识任何具体宿主类型。

契约 Domain 门面 / 应用 Application 效果 Effects 基础设施 Infrastructure 互斥 Mutex 池化 Pooling 内核 Runtime 外部 / 框架 编译期依赖 运行时调用 事件
调用方 · 宿主 / 玩法系统 实现 IBuffHost:Key + IsValid + TryGetCapability<T>() Apply / Remove / RemoveBySource / AttachHost / DetachHost / 查询 / 快照 BuffSystem(门面 · public sealed) IModule + IBuffEventNotifier · 主线程校验 状态 Created → Running → Releasing → Disposed 转发(已通过线程校验) BuffRuntime(内核 · internal sealed) 生命周期状态机:Applying / Active / Refreshing / Removing / Removed 四索引 + 三对象池 + 临时列表池 · 移除统一收口 TeardownInstance _operationDepth 操作边界事件冲刷 · 两阶段快照恢复 · Timer 代际守卫 运行时存储(内核独占) _hosts:BuffHostKey → IBuffHost 可申请宿主(DetachHost 先摘此索引) _containers:BuffHostKey → BuffHostContainer ByBuffId + OrderedInstances,空即回收 _instances:BuffRuntimeId → 实例 权威表 · runtimeId 单调递增不复用 _instancesBySource:BuffSourceKey → 列表 RemoveBySource 整组摘除 池:实例池 · 容器池 · token 池 · 临时列表池 Rent ↔ Return;池满即丢弃(DropCount) 注入内核的协作者 定义注册表 IBuffDefinitionProvider 策略注册表(3 个内置 + 自定义) 效果注册表(Key → 工厂) 定时器调度器 IBuffTimerScheduler 互斥规则 IBuffMutexRule(默认全放行) 事件通知器 IBuffEventNotifier
图 0-1:系统全景。左为内核独占的存储与池,右为构造期注入的六类协作者;箭头方向表示调用/引用方向,不代表数据所有权。

外部协作面

装配 · GameGlobal appCore.Register( () => new BuffSystem()) 取用 · BusinessCore Core.Get<BuffSystem>() 未就绪 → SYSTEM_NOT_RUNNING 依赖 · TimerManager GetDependencies() 声明 默认适配器 TimerManagerBuffScheduler 事件出口 · GlobalNotifier Dispatch(EventType, BuffLifecycleEvent) Applied / StackChanged / Removed / HostChanged 宿主侧接入现状:EntryManager 中「词条效果由 buff 承载」的接入点仍是 TODO;IBuffHost 目前只有测试替身(TestHost / ContractHost)与两个测试替身效果实现。
图 0-2:外部接线。模块只认 TimerManager 一个依赖,事件经 GlobalNotifier 单向流出。

11

产品文件(Domain 2 / Runtime 2 / 其余各 1)

3359

行产品代码(内核 BuffRuntime 独占 1376 行)

56 / 56

EditMode 用例通过(Buff 两组,2233 行)

61 类

C5 布局校验口径:61 类 / 502 成员块全部合规

1目录与类型

按目录看职责分层。public 为对外契约面,internal 为内核实现;所有类型共用命名空间 Game.Buff,不分子命名空间。

BuffSystem.cs360 行

模块门面:装配、线程纪律、生命周期、事件出口。唯一 public 入口,业务侧只见它。

BuffSystem : IModule, IBuffEventNotifier

BuffSystemOptions.cs17 行

容量与池化参数(4 组):实例 128/32/256、容器 8/64、token 32/256、临时集合 4/16/256(初容量 / 预热 / 保留上限)。

BuffSystemOptions

Domain/BuffContracts.cs388 行

全部对外契约:宿主抽象、值语义键、定义模型、枚举与结果码常量。

IBuffHost BuffIdBuffRuntimeIdBuffHostKeyBuffSourceKey BuffDefinitionBuffTimeDefinitionBuffEffectDefinition BuffSystemStateBuffLifecycleStateBuffDurationModeBuffStackDurationModeBuffEvent 5 个结果码常量类

Domain/BuffSnapshots.cs111 行

快照 / 事件 / 恢复报告三件套,是模块与外界交换状态的全部载体。

IBuffEventNotifier BuffRuntimeSnapshotBuffSystemSnapshot BuffRestoreReportBuffLifecycleEvent

Application/BuffApplication.cs430 行

应用策略与三个注册表:决定「已有同名实例时怎么办」,并在装配期把关定义合法性。

IBuffApplicationStrategyIBuffDefinitionProvider BuffApplicationAction StackableBuffApplicationStrategyReentryRejectBuffApplicationStrategyReentryReplaceBuffApplicationStrategy StackableApplicationParameters BuffApplyRequestBuffExistingInstanceInfoBuffApplicationContextBuffApplicationDecisionBuffApplicationResult BuffApplicationStrategyRegistryBuffDefinitionRegistry

Effects/BuffEffects.cs133 行

效果运行时契约 + 工厂注册表。效果是模块唯一的「向宿主施加影响」的出口。

IBuffEffectRuntimeIBuffEffectRuntimeFactory BuffEffectContext BuffEffectRegistry BuffEffectLease

Infrastructure/BuffTimerScheduler.cs115 行

定时器抽象与 TimerManager 适配器:把框架句柄包成 BuffTimerHandle,隔离框架变更。

IBuffTimerSchedulerTimerManagerBuffSchedulerBuffTimerHandle

Mutex/BuffMutex.cs63 行

互斥规则抽象与默认实现:三种决策 ALLOW / REJECT_INCOMING / REMOVE_EXISTING。

IBuffMutexRuleAllowAllBuffMutexRule BuffMutexExistingInfoBuffMutexContextBuffMutexResult

Pooling/BuffPools.cs246 行

两个通用池与统计结构:对象池带双重归还防护(IsInPool),列表池服务操作期临时集合。

IBuffPoolableBuffPoolStats BuffObjectPool<T>BuffListPool<T>

Runtime/BuffRuntime.cs1376 行

内核本体:状态机、四索引、Apply/Remove 全路径、事件冲刷、快照两阶段恢复、代际守卫。

BuffRuntime IndexScope(private enum) SnapshotIdentity(private struct)

Runtime/BuffRuntimeModels.cs120 行

三个池化载体:实例、宿主容器、定时器回调 token;各自实现 ResetForPool 清引用防跨池泄漏。

BuffRuntimeInstanceBuffHostContainerBuffTimerCallbackToken

2编译期依赖

依赖单向向下:门面 → 协作者 → 内核 → 契约 / 外部。内核可以调用策略、效果、定时器,但协作者之间不互相依赖; 只有 Application 因注册期校验效果 Key 而单向引用 Effects。

门面 BuffSystem.cs装配三注册表 + 构造内核 + 转发 BuffSystemOptions.cs4 组容量与池化参数(无依赖) 测试 · EditMode(56 用例 / 2233 行) Game.Buff.Tests:BuffSystemTests + BuffSystemContractTests 装配与注册 构造并持有内核 注入替身 协作者 Application/BuffApplication.cs策略 + 定义 / 策略注册表 Effects/BuffEffects.cs效果契约 + 注册表 Mutex/BuffMutex.cs互斥规则 Infrastructure/BuffTimerScheduler.cs定时器抽象 + TimerManager 适配 转发调用 运行时回调 内核 Runtime/BuffRuntime.cs状态机 · 四索引 · 事件冲刷 · 恢复 Runtime/BuffRuntimeModels.cs实例 · 容器 · 回调 token Pooling/BuffPools.cs对象池 · 列表池 · 统计 读写契约与快照 结果码 / 统计 契约 Domain/BuffContracts.csIBuffHost · 键类型 · 定义模型 · 枚举与结果码 Domain/BuffSnapshots.csBuffRuntimeSnapshot · BuffSystemSnapshot · BuffRestoreReport · BuffLifecycleEvent 帧 / 平台 日志 / 依赖注入 外部 Vant.CoreIModule Vant.System.TimerTimerManager(唯一模块依赖) Vant.System.LogVantLog Game.GlobalBusinessCore · GlobalNotifier 层内不互相依赖(无环);内核之上的协作者只被内核调用,不反向持有内核引用。
图 2-1:五层条带。箭头表示主要依赖方向;精确到文件的引用关系见下方矩阵。

文件级依赖矩阵

消费者 \ 被依赖 ContractsSnapshotsApplicationEffectsMutexInfraPoolingRtModelsRuntime外部
BuffSystem.cs✓✓✓✓✓✓✓—✓Core / Timer / Log / Global
BuffSystemOptions.cs——————————
Domain/BuffContracts.cs——————————
Domain/BuffSnapshots.cs——————————
Application/BuffApplication.cs✓——✓—————Log
Effects/BuffEffects.cs✓————————Log
Infrastructure/BuffTimerScheduler.cs—————————Timer
Mutex/BuffMutex.cs✓—————————
Pooling/BuffPools.cs—————————Log
Runtime/BuffRuntime.cs✓✓✓✓✓✓✓✓—Log
Runtime/BuffRuntimeModels.cs✓—————✓———
测试(EditMode)✓✓✓✓—✓——✓NUnit / TestTools

矩阵读法:依赖单向向下且无环;契约层零依赖是它唯一的稳定性来源。测试行是唯一允许引用内核文件的消费者(同程序集可见性由 asmdef 控制)。

3运行时对象与索引

内核的可变状态只有三种对象:实例(一个 buff 的一次挂载)、宿主容器(同一宿主下的实例集合)、回调 token(定时器回调的载体)。 三者全部池化,并通过四张索引表被检索。

四索引(内核持有) _instances : Dictionary<BuffRuntimeId, 实例> 权威表 · TryGet 走它 · id 单调递增不复用 _containers : Dictionary<BuffHostKey, 容器> ByBuffId 唯一性 · OrderedInstances 顺序 _instancesBySource : Dictionary<BuffSourceKey, 列表> source 列表与 buff 同寿,直接 new _hosts : Dictionary<BuffHostKey, IBuffHost> 可申请宿主(DetachHost 先摘它) BuffRuntimeInstance(池化) RuntimeId : BuffRuntimeId 单调递增、不复用 Definition : BuffDefinition 定义(注册表持有) HostKey : BuffHostKey 宿主身份 SourceKey : BuffSourceKey 来源身份(可空) StackCount : int 当前层数 State : BuffLifecycleState 生命周期状态 TimerHandle / TimerGeneration / TimerToken Payload : object 不入快照,首次应用后权威 Leases : List<效果定义+工厂+运行时> IsInPool(池写入,外部只读) ResetForPool 清 Payload / Leases,防跨池引用泄漏 BuffHostContainer(池化) HostKey : BuffHostKey Host : IBuffHost 卸载时用于回滚效果 ByBuffId : Dictionary<BuffId, 实例> OrderedInstances : List<实例> BuffTimerCallbackToken(池化) Callback : Action 构造期固定,不再分配 Bind(runtime, scheduler, runtimeId, gen) SetHandle(handle) 调度成功后才写入 ResetForPool:清空 runtime / scheduler 引用 四个池(全部暴露 BuffPoolStats) 实例池 BuffObjectPool 预热 32 / 上限 256 容器池 BuffObjectPool 预热 8 / 上限 64 token 池 BuffObjectPool 预热 32 / 上限 256 临时列表池 BuffListPool 预热 4 / 上限 16 / 单表容量 256 归还路径:TeardownInstance / ReturnCandidate ⇒ 实例入池;容器清空 ⇒ 入池;CancelTimer / 触发后 ⇒ token 入池;操作结束 ⇒ 临时列表入池。 三条硬不变量 ① 同宿主同 buff 至多一个实例:由容器 ByBuffId 与 CommitCandidate 的提交前双守卫共同保证(重复提交被拒绝,不会覆盖产生孤儿实例)。 ② runtimeId 永不复用:池回收的是对象,不是身份;实例归还时 RuntimeId 被清空。 ③ 每条移除路径恰好归还一次:TeardownInstance 单点收口,命中 Removing / Removed / Refreshing 守卫时立即返回且不做任何修改。 已知缺口:DetachHost 与 RemoveBySource 为省 O(n²) 会先整体摘除索引再逐条撤实例;若某条被守卫挡下(叠层刷新中), 会出现「实例仍在 _instances、但已不在任何宿主 / source 索引里」的短暂不自洽态:TryGet 查得到、GetByHost 查不到。 定时器到期或显式 Remove(runtimeId) 可自愈;无限时长的 buff 会残留到显式移除。修法:逐条摘索引 + 守卫失败回滚索引。
图 3-1:运行时对象与索引。索引只存引用,不拥有生命周期;所有权唯一归属 _instances。

4Apply 决策流

一次 Apply 先过六道拒绝门(每道都只记日志、不改状态),再由策略决定四个动作之一; 新建路径必须连过「候选创建 → 提交双守卫 → 定时器」三关才算成功。

① 线程与状态门 EnsureMainThread + State == Running ⇒ 否则 SYSTEM_NOT_RUNNING BeginOperation() 操作边界起点 ② 请求门 request.IsValid(BuffId + HostKey 均非空)⇒ 否则 INVALID_REQUEST ③④ 定义 / 宿主门 ⇒ DEFINITION_NOT_FOUND · HOST_NOT_FOUND ⑤ 既有实例探测 container?.ByBuffId[BuffId] ⇒ existing(可为 null) ⑥ 互斥评估 EvaluateMutex REJECT_INCOMING ⇒ MUTEX_REJECTED | REMOVE_EXISTING ⇒ 收进清单 ⑦ 策略与参数门 ⇒ UNKNOWN_APPLICATION_STRATEGY · INVALID_STRATEGY_PARAMETERS ⑧ strategy.Decide → 决策四选一 CreateNew | AddStack | ReplaceExisting | Reject Reject ⇒ 返回 decision.ResultCode REENTRY_REJECTED | STACK_LIMIT_REACHED AddStack ⇒ 叠层路径(第 ⑤ 节) 先消费互斥移除,再 ApplyStack 守卫:无实例 / Refreshing 重入 ⇒ INVALID_REQUEST ReplaceExisting ⇒ 先移除旧实例 原因码 REENTRY_REPLACED,再新建 未知动作 ⇒ INVALID_REQUEST 显式兜底,防止绕过孤儿守卫 ⑨ CreateCandidate(尚未进索引) Rent 实例 → runtimeId = _nextRuntimeId++ → State = Applying RentAndApplyEffects 正序;失败 ⇒ 逆序 Revert + 归还 ⇒ EFFECT_APPLY_FAILED ⑩ CommitCandidate(提交双守卫) runtimeId 已存在 | 容器已有同 buffId ⇒ 拒绝提交 通过 ⇒ 写 ByBuffId / OrderedInstances / _instances(+ source) ⑪ ScheduleTimer(仅有限时长) 失败 ⇒ 归还候选 + TIMER_SCHEDULE_FAILED ⑫ NotifyResult(Applied) + EndOperation() 事件在操作边界统一冲刷 ⇒ 消费者看到的是稳定状态 孤儿守卫 CreateNew 撞既有实例 ⇒ INVALID_REQUEST 结果码面:一次 Apply 只会带出其中一个码。 成功:APPLIED / STACKED / REPLACED 拒绝:MUTEX_REJECTED / REENTRY_REJECTED / STACK_LIMIT_REACHED 失败:DEFINITION_NOT_FOUND / HOST_NOT_FOUND / EFFECT_APPLY_FAILED / TIMER_SCHEDULE_FAILED / UNKNOWN_APPLICATION_STRATEGY / INVALID_STRATEGY_PARAMETERS 参数错误:INVALID_REQUEST / SYSTEM_NOT_RUNNING 六道拒绝门全部发生在 任何状态修改之前。
图 4-1:Apply 主流程。红色虚线为两道防守型守卫(未知动作 / 孤儿实例)。

5叠层路径

叠层不新建实例,而是把既有实例推进 Refreshing 状态、通知每个效果层数变化,再按策略给的时长模式重排定时器。 时序被设计成「先成功、后失效」:任何一步失败都回滚层数,绝不留下「有层数、无定时器」的半成品。

入口:Apply → AddStack 分支(互斥移除已消费) 守卫:无既有实例 / existing.State == Refreshing ⇒ INVALID_REQUEST ApplyStack ① previousStackCount = StackCount ② StackCount = previous + 1 ③ State = Refreshing ④ 逐效果正序 OnStackChanged,stackedCount++ 效果抛异常也会被计入 stackedCount,从而在回滚时被正确逆序撤销 UpdateTimerForStack(仅有限时长) 无限时长 或 KeepDuration ⇒ 直接成功,定时器不动 RefreshDuration ⇒ 用定义时长重排 ExtendDuration ⇒ 剩余 + 延长时间 先排新 timer 成功 ⇒ 再 Cancel 旧句柄 + 归还旧 token 失败 ⇒ 返回 false(实例字段一个都没动) 成功 ⇒ State = Active · 返回 STACKED NotifyResult(StackChanged, STACKED) 失败 / 异常 ⇒ RollbackStack 逆序 OnStackChanged StackCount 回退 · State = Active 返回 TIMER_SCHEDULE_FAILED 或 EFFECT_APPLY_FAILED 三条与叠层有关的不变量 ① 叠层期间实例不可被撤除:TeardownInstance 守卫包含 Refreshing ⇒ 在 OnStackChanged 回调里重入 Remove / RemoveBySource / DetachHost 都不会拆掉正在刷新的实例(这一条缺失时,空引用会逃逸到调用方,且已归还池的实例会被继续写入)。 ② 叠层不可重入:Apply 的 AddStack 分支拒绝 existing.State == Refreshing,防止层数被重复 +1。 ③ 时长失败必须回滚:UpdateTimerForStack 返回 false 时层数回退并返回 TIMER_SCHEDULE_FAILED,避免有限时长 buff 静默变成永久持续。 同一实例的叠层与移除都发生在主线程,因此不存在数据竞争;真正的重入来源只有一个:效果回调里再次调用模块 API。 叠层回调期间若走批量移除入口(RemoveBySource / DetachHost),会出现索引不自洽的短暂态(见第 ③ 节缺口说明)。
图 5-1:叠层路径。红色箭头是失败分支,其唯一结果是「层数回到原值」,不会留下损坏状态。

6移除路径

六条移除入口,一个收口点。TeardownInstance 负责「撤效果 → 摘索引 → 发事件 → 归还池」, 因此不存在第二条把实例从索引里拿掉的代码路径;批量入口只额外做一件事:先整体摘索引,再逐条撤。

Remove(runtimeId) 显式单个移除 reason 默认 MANUAL RemoveBySource(key) 按来源整组 返回移除条数 DetachHost(hostKey) 宿主卸载 HOST_DETACHED HandleTimerFired 定时到期 EXPIRED(剩余量显式 0) RestoreSnapshot 清场 读档替换全部实例 SUPERSEDED_BY_RESTORE Release → RemoveAll 模块关停 SYSTEM_RELEASED(抑制事件) TeardownInstance(唯一收口) ① 守卫:State ∈ { Removing, Removed, Refreshing } ⇒ 立即返回 false,一个字段都不碰 ② State = Removing;剩余毫秒 = remainingMsOverride ?? QueryRemainingMs(实例) ③ CancelTimer:取消句柄并归还 token(句柄无效则跳过) ④ ReturnEffects:逐效果逆序 Revert(宿主缺失则跳过 Revert 并记 Warning)+ ResetForPool + 工厂归还 ⑤ RemoveFromIndices(IndexScope):按位掩码维护 _instances / 容器 / _instancesBySource ⑥ State = Removed ⑦ 造快照(抑制事件或未接通知器时为 null)+ NotifyRemoval(原因码, 快照) ⑧ 实例归还池:ResetForPool 清空全部引用(含 Payload 与 Leases) ④ 不因失败而中断:单效果 Revert 返回 false 或抛异常只记 Error,其余效果照常回滚、实例照常归还 ⇒ 移除一定完成。 ⑦ 快照在归还池之前生成,携带的是移除前的真实剩余毫秒(过期路径为 0,无限持续为 -1)。 IndexScope 位掩码 None(0):三类索引都已被调用方整体清过,无需逐条维护 Instances(1):从权威表按 runtimeId 移除 Container(2):从宿主容器的 ByBuffId 与 OrderedInstances 移除,空容器归还池 Source(4):从来源列表移除,空列表连键删除 All(7):单条移除路径的默认值(Remove / 定时到期 / 显式移除) 各入口取值:Remove ⇒ All;RemoveBySource ⇒ Instances | Container; DetachHost ⇒ Instances | Source;批量 RemoveAll(整份清场)⇒ None。 七种移除原因码 EXPIRED:定时到期(剩余量 0) MANUAL:调用方显式移除 MUTEX_REMOVED:被新 buff 的互斥规则挤掉 REENTRY_REPLACED:被重入替换策略换掉 HOST_DETACHED:宿主卸载 SYSTEM_RELEASED:模块关停 SUPERSEDED_BY_RESTORE:读档恢复前的清场 批量入口的共同形状:先用临时列表快照待移除实例 → 整体摘除该类索引 → 逐条 Teardown → 归还临时列表。临时列表每轮只租借一次,不产生额外分配。 缺口:若某条在①被守卫挡下(叠层刷新中),会出现「实例仍在 _instances、但已不在宿主/source 索引里」的短暂态;定时器到期或显式 Remove(runtimeId) 可自愈。
图 6-1:移除路径。批量入口与单条入口共用同一个收口点,区别仅在「谁负责摘索引」。

7事件模型

三种逐实例事件 + 一种宿主级事件。逐实例事件在操作边界统一派发(消费者拿到的永远是稳定状态,而非中途态); HostChanged 不需要任何额外埋点,它由已产生的事件自动推导。

Applied Apply 新建成功 ⇒ APPLIED 快照恢复逐条 ⇒ RESTORED StackChanged 叠层成功 ⇒ STACKED 成员未变 ⇒ 不派生 HostChanged Removed 任何移除 ⇒ 带原因码 剩余毫秒为移除前快照值 Dispatch(BuffLifecycleEvent) 先入待派发表 _pendingEvents : List<BuffLifecycleEvent>(复用,不在堆上反复分配) 操作中:_operationDepth > 0 只入表,不派发——等操作边界统一冲刷 操作外:depth == 0 入表后立即走 FlushPending EndOperation() :depth −− ,降到 0 时冲刷 FlushPending(一轮冲刷) ① _operationDepth 置 1:冲刷期间消费者重入产生的事件仍在同一轮内派发 ② 按原序 NotifyOne 派发逐实例事件(逐个 try/catch 隔离,单个消费者报错不影响其余) ③ 扫描本批:仅 Applied / Removed 视为成员变化,按 HostKey 去重后追加派发 HostChanged finally:清空事件表与宿主表,深度归零 Global Notifier 四个约定 ① 批量天然合并:DetachHost 撤 N 个实例 ⇒ N 条 Removed + 1 条 HostChanged,UI 只需失效一次。 ② HostChanged 不带快照,只表示「该宿主的 buff 列表需要重读」;ResultCode 与 RemovalReason 均为 null。 ③ 叠层不触发 HostChanged;而恢复清场也参与派生,因此「恢复后一个 buff 也不剩」的宿主同样收得到失效信号。 ④ 唯一抑制事件的路径是 Release(关停);其余包括快照恢复在内都不抑制。
图 7-1:事件模型。三种逐实例事件是事实来源,宿主级事件是它们的派生结果。

8快照与恢复

fail-closed 只针对快照的内部矛盾:快照自己矛盾(id 非法 / 重复)就整份拒绝;快照引用了外部解析不了的东西 (定义被删、宿主不在场、剩余量非法)则只跳过那一条。这条界线是为了避免「删掉一条 buff 配表 ⇒ 整份存档的 buff 全丢」。

CaptureSnapshot() 按 runtimeId 排序(消除字典顺序)⇒ 逐实例出快照 captureEffectStates 仅存档路径为 true 返回 BuffSystemSnapshot(实例快照数组) 快照携带什么 驱动恢复:BuffId · HostKey · SourceKey · StackCount RemainingDurationMs · EffectStates(逐效果状态) 仅诊断:State · DurationMode · UseScaledTime(恢复不读) RestoreSnapshot(snapshot) = BeginOperation + 恢复内核 + EndOperation 整次恢复合并为一轮事件冲刷 阶段 A · 整份拒绝(内部矛盾) RuntimeId.IsValid == false ⇒ INVALID_IN_SNAPSHOT RuntimeId 重复 ⇒ DUPLICATE_IN_SNAPSHOT 同宿主同 buff 重复 ⇒ DUPLICATE_IN_SNAPSHOT 命中任意一条:只记错误、不改状态、直接返回报告 (报告 Errors 非空、Restored 为空、SkippedCount 为 0) 阶段 B · 逐条跳过(外部不可解析) 定义缺失 ⇒ DEFINITION_NOT_FOUND 有限时长剩余量 ≤ 0 ⇒ RESTORE_FAILED 宿主不在场 ⇒ HOST_NOT_FOUND 候选创建 / 定时器 / 提交失败 ⇒ 对应业务码 每条:记错误 + SkippedCount++ + 继续下一条 恢复提交(逐条) ① 清场:RemoveAll(SUPERSEDED_BY_RESTORE, suppressEvents: false) ⇒ 既有实例逐条发 Removed ② CreateRestoredCandidate:走效果的 RestoreState(而非 Apply),Payload 置 null,层数取 max(1, 快照层数) ③ ScheduleTimer(剩余毫秒):有限时长才排;失败 ⇒ 归还候选 + 计一次跳过 ④ CommitCandidate:与 Apply 共用同一套双守卫 ⇒ 快照里与现存实例冲突的条目会被拒掉而非覆盖 ⑤ AddRestored + NotifyResult(Applied, RESTORED):逐条发恢复成功事件 ⑥ 收尾:_nextRuntimeId 抬升到快照内最大 id + 1,保证后续新建不会与既有实例撞号 BuffRestoreReport 四字段的读法 Restored:逐条恢复成功的 runtimeId 列表;Errors:每条一个「错误码:标识」字符串;Succeeded = Errors 为空。 SkippedCount 为 0 且 Restored 为空 ⇒ 阶段 A 整份拒绝;SkippedCount > 0 ⇒ 阶段 B 有条目被跳过。两者都把 Succeeded 置为 false。
图 8-1:快照与恢复。恢复不重放历史,而是「清场 + 重建到快照描述的状态」。

9定时器与代际守卫

有限时长 buff 用一层适配器把框架定时器包起来:调度是原子的(失败时实例字段一个都不动),取消与重排保证「任一时刻至少一个有效定时器」, 回调入口再做三重比对把陈旧回调挡在外面。

ScheduleTimer(durationMs) nextGeneration = TimerGeneration + 1(仅存局部) token = token池.Rent() ⇒ Bind(runtime, scheduler, runtimeId, nextGeneration) handle = scheduler.Schedule(durationMs, !UseScaledTime, token.Callback) 失败 ⇒ 归还 token,实例字段一个都不改(真原子) 成功 ⇒ token.SetHandle(handle) 后一次性提交: TimerGeneration = nextGeneration TimerHandle = handle TimerToken = token 触发:TimerManager → token.OnFired scheduler.Complete(handle):仅清登记,不撤销 runtime.HandleTimerFired(runtimeId, generation) 两个参数来自 token 自己的捕获字段,不依赖实例现状 三重比对(不满足即丢弃) ① _instances 含此 runtimeId? ② 实例 State == Active? ③ 实例 TimerGeneration == 代际参数? 通过 ⇒ 过期移除 归还 token;TimerToken / TimerHandle 置空 BeginOperation → RemoveInternal(EXPIRED, 剩余量 0) → EndOperation Removed 事件携带的剩余毫秒因此是 0,而不是 -1 叠层重排(先成功后失效) ① 保存 oldHandle / oldToken ② ScheduleTimer(新时长) ⇒ 失败即返回 false,旧定时器仍然有效 ③ 成功后:Cancel(oldHandle) + 归还 oldToken 任一时刻至少一个有效定时器 ⇒ 不会出现「无定时器的有限时长 buff」 时长:KeepDuration 不动,RefreshDuration 用定义时长,ExtendDuration 用剩余 + 延长 三层防护与已知缺口 第一层(契约):Cancel 成功后回调不得再触发 —— 这是 IBuffTimerScheduler 实现者的义务,生产实现由 TimerManager 的 Cancel→ReleaseEntry 满足。 第二层(生命周期):token 归还时置空 runtime / scheduler 引用,回调即使跑起来也找不到内核。 第三层(代际):TimerGeneration 每实例计数,与池化的 token 一起构成纵深防御。 缺口:代际是「每实例」而非全局单调,池 LIFO 复用同一 token 时,不同实例可能持有相同代际值,因此第三层挡不住假想的 token 复用别名; 当前生产安全完全依赖第一层契约(已写入接口文档)。真要修需把代际按注册次不可变捕获,代价是每次调度一次分配。
图 9-1:定时器路径。调度原子、重排先成功后失效、触发三重比对是本模块对「时间」的全部防御。

10附录 · 契约与残留

画完图之后最该记住的东西:哪些是硬约束(改了就破坏抽象),哪些是已知取舍(知道就好,别当 bug 修)。

一、硬约束

约束内容
主线程 only模块全部 API 只允许主线程调用(含查询与快照);跨线程调用被拒绝并记 Warning,返回 SYSTEM_NOT_RUNNING。
宿主无关模块只认 IBuffHost。需要宿主侧能力时走 TryGetCapability<T>();效果实现不得放进模块内部去引用具体宿主类型。
事件延迟派发事件在操作边界统一冲刷,且派发时会先把内核状态摆到稳定态;消费者不得依赖「回调时操作已结束」以外的时序假设。
单一收口实例的撤除只有 TeardownInstance 一条路径;新增移除需求应接入这个收口点,而不是另写一套摘索引逻辑。
索引与所有权_instances 是唯一权威表;容器与 source 列表都只是引用集合,不得单独作为「实例是否存在」的依据。
效果调用序列Apply →(OnStackChanged)* → Revert → ResetForPool;多效果按定义顺序正序应用、逆序撤销;没有中间「结算」阶段(已删除的 Refresh 就是为了取消这个概念)。
装配顺序效果工厂必须先于引用它们的定义注册;策略必须先于使用它的定义注册(定义注册期会校验两者)。
注册重复即拒绝三个注册表对重复 Key 一律拒绝并返回 DUPLICATE_REGISTRATION;编程错误(null 实参)仍抛异常。
fail-closed 的边界快照内部矛盾 ⇒ 整份拒绝;外部引用解析不了 ⇒ 逐条跳过。修改这条界线会让「删一条配表」变成「整份存档 buff 全丢」。
值语义键BuffId/BuffRuntimeId/BuffHostKey/BuffSourceKey 是 readonly struct,相等性与哈希已实现且无装箱;不要用字符串当键传阅。
Payload 不入快照实例级载荷只服务于「本次挂载」,跨存档状态必须由效果自己用 CaptureState/RestoreState 持久化。
池化纪律池化对象归还前必须 ResetForPool(清引用防跨池泄漏);IsInPool 只由池写入,外部只读。

二、已知残留与取舍

项级别状态与理由
批量移除的索引不自洽态缺口DetachHost/RemoveBySource 为省 O(n²) 先整体摘索引再逐条撤;若某条被 Refreshing 守卫挡下,会出现「TryGet 查得到、GetByHost 查不到」的短暂态。定时到期或显式 Remove(runtimeId) 可自愈;无限时长 buff 会残留到显式移除。修法:改成逐条摘索引 + 守卫失败即回滚索引。
代际守卫防不住 token 复用别名缺口TimerGeneration 是每实例计数而非全局单调,池 LIFO 复用 token 时不同实例可能同代际。生产安全依赖调度器的 Cancel 契约(已写入 IBuffTimerScheduler 文档)。
有限时长 buff 的「永久化」风险已封叠层重排失败会回滚层数并返回 TIMER_SCHEDULE_FAILED;首次 Apply 排定时器失败会归还候选。不存在「层数加了但没定时器」的路径。
叠层回调中重入移除已封TeardownInstance 守卫包含 Refreshing;且 Apply 的 AddStack 分支拒绝重入叠层。
叠层路径丢失互斥移除已封互斥移除清单在首层与叠层两条路径都被消费,语义对称。
读档后 UI 不更新已封恢复清场不再抑制事件,且逐实例事件会自动派生 HostChanged;「恢复后一个 buff 也不剩」的宿主同样收得到信号。
效果层的 Refresh 阶段已删全模块只有一个调用点、与 OnStackChanged 信息量相同,而「重算综合影响」的职责归属性系统(PropertyBlock 基线 + 修饰重算)。效果只需 Apply / OnStackChanged / Revert。
定义注册表文档与实现不一致已修重复 Id 改为拒绝(与兄弟注册表对齐),类文档同步为「编程错误抛异常 / 数据错误返回码」。
过期移除的剩余毫秒恒为 -1已修过期路径显式传 0,-1 重新回到「无限持续 / 句柄未知」单一含义。
模块内 P3 打磨项记录结果码常量类混域、部分 public 类型实际仅内部使用(可见性收窄需先评估 InternalsVisibleTo 成本)、BuffTimerHandle 缺 ==/!=、池化对象只 Clear 不 Trim 等;均不影响行为。

三、演进记录(2026-9-13)

批次内容
独立审查四个只读审查镜头(生命周期 / 契约 / 内存 / 测试)并行分头审,主线程逐条复核后坐实 3 个 P1、4 个 P2 与两个 P0 级测试缺口。
修复五批①三个 P1 + 迭代注释 ②两个 P0 测试缺口(含改造定时器替身支持多代并存与按 id 触发)③四个 P2 ④剩余测试缺口 ⑤P3 打磨。用例 35 → 54。
复核发现五批全绿后逐行复核另择出两个新问题:重编号引入的「未知动作无兜底」回归(已加守卫)与一个把语义不同的键类型改成同名的陷阱(已改名并注释)。
两项演进删除效果接口的 Refresh(7 → 6 个成员);新增宿主级 BuffEvent.HostChanged,复用已有的事件缓存机制零额外埋点。用例 54 → 56。
布局规范新增 C5 源文件布局规范与校验工具,本模块 61 类 / 502 成员块全部合规;测试目录不纳入 C5(测试文件按「用例在前、替身在后」惯例)。

四、本图维护说明

本图是现状快照,依据 HEAD 62ef744 与当时工作区(Buff 审查五批修复 + 两项演进)。 代码结构变动(新增/删除类型、索引或池、改动 Apply/Remove/事件/快照的步骤)后需重新绘制; 纯实现细节(方法名、行内逻辑)变动不影响本图。图内不写行号,引用一律用 文件:符号。