diff --git a/dev.md b/dev.md index 54daffd..2938719 100644 --- a/dev.md +++ b/dev.md @@ -70,7 +70,7 @@ ### 注释规范 -- 公共方法、接口必须在**源头处**(多数情况下为 `interface`)添加 `jsDoc` 注释;其他常用成员、方法、类型也必须添加注释(含义极为明确或极少使用的可例外,但建议全部添加)。 +- 公共方法、接口必须在**源头处**(多数情况下为 `interface`)添加 `jsDoc` 注释;其他常用成员、方法、类型也必须添加注释(含义极为明确或极少使用的可例外,但建议全部添加)。接口中每个方法之间必须添加空行,成员之间应根据成员功能添加换行。 - 继承或 `implements` 而来的 API(方法、成员等),若注释说明无需变更,则**不应重复添加** `jsDoc` 注释。 - 不对构造器添加注释。若构造器使用了属性声明语法(`constructor(public prop: T)`)且成员需要说明,可仅对该成员添加参数注释,不写构造器描述。**在这种情况下**建议避免在构造器中使用属性声明语法,将成员单独声明并在构造器中赋值。此条建议**并非**要求不使用构造器的属性声明语法,而是仅在这一情况下不建议使用,常规情况下推荐使用此语法来缩短代码长度并提高可读性。 - 长文件可使用 `#region` / `#endregion` 分段以支持折叠。 @@ -78,7 +78,7 @@ - 单行注释的 `//` 与注释内容之间留一个空格;不允许出现非 jsDoc 的多行注释,如需多行注释,使用多个单行注释代替。 - 注释合理换行:考虑中文字符较宽,建议每 40–60 个字符在标点符号后换行,不要频繁换行,每行长度应差不多,不允许在句中换行;参数注释换行后保持对齐。 - 单行注释结尾不加句号;较长的多行注释结尾可加句号。 -- 一般不建议给接口(`inteface` 本身)、类型别名或类本身写注释(不好看),特殊情况除外。 +- 一般不建议给接口(`inteface` 本身)、类型别名、枚举或类本身写注释(即需要紧接着一长段缩进的时候,这时候写注释不好看),特殊情况除外。 - 对于 jsDoc 注释,方法注释必须使用换行风格;对于成员 jsDoc 注释,除非注释较长需要换行,否则使用不换行的风格。 ### 类型规范 @@ -107,6 +107,7 @@ - 极度不建议写 `getter` 和 `setter`,但在极少数场景下还是可以使用的,一般是必须要提供运算符操作方法且需要监听 `get` 和 `set` 时才允许使用。 - 方法实现中,未使用的后置参数直接不填,不得写以下划线开头的参数名。 - 使用对象解构语法时,除非明确后续可能新增其他变量,否则不允许出现单个的对象解构,如 `const { value } = obj`,必须写成 `const value = obj.value`。 +- 换行使用 `CRLF` 格式。 ## 双端分离 diff --git a/docs/dev/replay/replay-array.md b/docs/dev/replay/replay-array.md new file mode 100644 index 0000000..f97c067 --- /dev/null +++ b/docs/dev/replay/replay-array.md @@ -0,0 +1,151 @@ +# 需求综述 + +录像由数万条录像步组成,每条录像步包含可变数量的参数,参数类型涵盖数字、字符串、布尔值和 bigint。直接操作 `ArrayBuffer` 的字节布局会导致系统耦合存储格式细节、修改录像困难。需要一个独立工具封装底层存储的读写,并提供插入、删除、修改等编辑能力。 + +# 需求理解 + +## 明确需求 + +1. 变长指令编码。指令码使用 `uint8` 或 `uint16`,宽度取决于已注册命令的最大 ID。 +2. 自描述参数格式。参数使用类型标识 + 变长内容的编码,每条录像步的参数数量可变。类型标识同时承担短字符串的长度信息,节省额外字节。 +3. 录像可编辑。支持在任意有效位置插入、删除、修改录像步——不是只追加的日志。 +4. 顺序流式读取。播放是按顺序逐帧进行的,需要流式读取器避免每次随机访问的 O(n) 偏移扫描。 + +## 隐含需求 + +1. 容量管理。`ArrayBuffer` 不可动态扩容,写入前需检查容量,不足时分配新缓冲并复制。 +2. 偏移累积。指令数组中的每条录像步长度不固定,随机访问某一条时需从头扫描累积偏移。流式读取器可以利用顺序访问特性只扫描一次。 + +# 设计前提 + +1. 双缓冲存储。指令数组和参数数组各自使用独立的 `ArrayBuffer`,通过 `DataView` 读写。 +2. 不可变元数据。`IReplayMetadata` 中的格式参数在初始化时确定,读取时不修改。 +3. 边解码边播放。不一次性解码全部数据——沙箱通过流式读取器按需解码。 + +# 核心概念定义 + +## 指令数组 + +录像中所有指令码与参数数量的序列化存储。每条录像步在指令数组中占用 1+W 字节(W 为指令码宽度):第一个 `uint8` 为参数数量,后续 `uint8` 或 `uint16` 为指令码。 + +## 参数数组 + +录像中所有参数内容的序列化存储。每个参数由一个 `uint8` 类型标识开头,后接对应类型的二进制内容。类型标识同时编码短字符串的长度(8-256 → 长度 n-7)。 + +## 流式读取器 + +持有一个内部偏移指针,每次调用 `read` 解码并返回当前指针指向的录像步,然后推进指针。由于指令数组中每条录像步的总字节数不固定,指针以步索引而非字节偏移为单位——读取器内部维护已扫描过的累积字节偏移,每次 `read` 仅解码一步。 + +## 指令宽度 + +指令码的存储字节数。初始为 `uint8`(1 字节)。命令 ID 上限在 `registerCommand` 注册时即可获知——若注册了 > 255 的 ID,直接初始化为 `uint16`,不涉及运行时重编码。若需要变更已存在录像的指令宽度(例如切换为更大的指令码空间以兼容新增命令,或压缩为更小的空间以节省体积),则通过 `setCommandWidth` 方法将全部指令条目按新宽度重编码。 + +# 接口设计分析 + +## `IReplayArray` + +### 设计思路 + +由于录像需要支持任意有效位置的插入和删除——比如玩家在已录制的操作序列中间补充了额外操作,或者修复了某段错误的录像——只提供追加写入是不够的。因此需要 `insert` 和 `delete` 方法,以及在原位置修改的 `set` 方法。追加仍然是最频繁的操作——因此需要 `add` 方法。 + +由于沙箱播放是顺序推进的,每次调用 `get(index)` 都需要从头扫描积累偏移——因此设计 `createReadStream` 创建流式读取器。流式读取器内部维护步索引指针和已累积字节偏移,每次 `read` 仅解码一步,将偏移计算从 O(n²) 降为 O(n)。同时保留 `get` 用于跳转播放等非顺序场景。 + +由于命令 ID 上限在注册阶段已知,指令宽度在 `IReplayArray` 构造时即确定——若最大 ID > 255 则初始化为 `uint16`。但已存在的录像可能需要变更宽度——例如加载旧 `uint8` 录像后注册了 > 255 的新命令需要升级,或者想将 `uint16` 录像压缩回 `uint8` 以节省存储。因此需要 `setCommandWidth` 方法将全部指令条目按新宽度重编码。 + +### 接口分析 + +- 成员 `length: number`:预期频率**中频**。安全系统检测步数变化、沙箱判断播放是否结束。 +- 成员 `metadata: IReplayMetadata`:预期频率**低频**。存档时读取以持久化格式参数。 +- 方法 `add(id: number, params: ReplayParamValue[])`:预期频率**高频**。每次 `record` 调用一次。 +- 方法 `insert(index: number, id: number, params: ReplayParamValue[])`:预期频率**低频**。`index` 等于 `length` 时等同于 `add`;`index` 超出 `length` 时触发警告并忽略。 +- 方法 `delete(index: number)`:预期频率**低频**。 +- 方法 `set(index: number, id: number, params: ReplayParamValue[])`:预期频率**低频**。 +- 方法 `get(index: number): IReplayStepHandler`:预期频率**低频**。跳转播放时使用。 +- 方法 `createReadStream(startIndex?: number): IReplayReadStream`:预期频率**中频**。沙箱播放时调用。 +- 方法 `setCommandWidth(width: ReplayCommandWidth)`:预期频率**低频**。变更已存在录像的指令码宽度,将全部指令重编码。 + +### 预期体量 + +预期代码体量 300-360 行。 + +- 参数编码预期 100 行。七种参数类型各自需要独立的写入逻辑:布尔值为类型 0 接 1 字节;int8/int16/int32/int64/float64 各为一个单字节类型标识(1-5)接对应宽度的定长写入;bigint 需类型 6 接 `uint16` 长度再接 n 个 `int32`;短字符串将长度编码进类型字节(8-256,长度 = n-7),`TextEncoder` 编码后直接写入内容;长字符串用类型 7 接 `uint16` 长度再写内容。每种类型还需容量检查与扩容,七种类型的分支代码量相当可观。 +- 参数解码预期 80 行。与编码对称的七种读取分支,根据类型字节分发。数值类型按宽度读,bigint 读长度后拼接 int32,短字符串从类型字节反推长度后 `TextDecoder` 解码,长字符串先读长度。每条分支均含边界检查。 +- 指令读写预期 60 行。写入时根据当前 `commandWidth` 决定写入 1 还是 2 字节,读取时同理。每条录像步的指令部分 = `uint8` 参数数量 + W 字节指令码。`get` 和 `createReadStream` 均需维护字节偏移——每次读取或跳过一条录像步时,根据上一条的参数数量和参数总字节长度计算下一条的起始偏移。 +- 插入与删除预期 60 行。`insert` 需将指定位置及之后的所有指令和参数数据向后移动以腾出空间,再在空位写入新步。`delete` 则反向——将之后的数据向前移动覆盖被删除步。参数数组的移动量取决于被操作步的参数总字节数,需先解码获取。`set` 本质是先 `delete` 后 `insert`。 +- 容量管理与复制预期 30 行。两个 `ArrayBuffer` 各自独立管理容量,写入前检查,不足时分配更大新缓冲(如 2 倍增长),逐字节复制旧数据后再继续写入。 +- 流式读取器预期 25 行。内部维护步索引、指令数组字节偏移、参数数组字节偏移。`read` 解码当前步并推进三个变量,返回 `IReplayStepHandler`,超出 `length` 时返回 null。构造时可选 `startIndex`,需扫描至指定位置。 +- 指令宽度变更预期 25 行。`setCommandWidth` 遍历全部已写指令条目——解析每条录像步的参数数量和原宽度的指令码——按新宽度重新写入指令数组。`commandWidth` 可能变大也可能变小,编码前需根据新宽度判断每条指令码是否在范围内(例如 `uint8` 模式下指令 ID > 255 应报错)。 + +## `IReplayReadStream` + +### 设计思路 + +由于沙箱播放是顺序推进的,流式读取器持有当前步索引指针及已累积的字节偏移,每次调用 `read` 仅解码一步且指针自动推进——避免了 `get(n)` 每次 O(n) 扫描的累积开销。读取完毕后返回 null,调用方可判断播放结束。 + +### 接口分析 + +- 成员 `index: number`:预期频率**低频**。显示当前播放进度。 +- 成员 `length: number`:预期频率**低频**。用于判断是否已读取完毕。 +- 方法 `read(): IReplayStepHandler | null`:预期频率**高频**。沙箱每步调用一次。 + +### 预期体量 + +预期代码体量 25 行。 + +## `IReplayMetadata` + +### 设计思路 + +指令码宽度在读取时必须知晓才能正确解析每条录像步。`uint8` 和 `uint16` 是不同的编码模式,不是一个可变参数——因此用枚举 `ReplayCommandWidth` 标记,存档时持久化此枚举值。 + +### 接口分析 + +- 成员 `commandWidth: ReplayCommandWidth`:预期频率**低频**。初始化时根据最大命令 ID 设定,指令读写时使用。 + +### 预期体量 + +预期代码体量 5 行(含枚举定义)。 + +--- + +# 实现思路 + +## 参数编码格式 + +| 类型标识 | 参数类型 | 内容编码 | +| -------- | --------------------- | ------------------------------------------------- | +| 0 | 布尔值 | 1 字节:0 或 1 | +| 1 | int8 | 1 字节 | +| 2 | int16 | 2 字节 | +| 3 | int32 | 4 字节 | +| 4 | int64 | 8 字节 | +| 5 | float64 | 8 字节 | +| 6 | bigint | `uint16` 长度 N,后接 N 个 `int32` | +| 7 | 长字符串(≥250 字节) | `uint16` 长度 N,后接 `TextEncoder` 编码的 N 字节 | +| 8-256 | 短字符串(n-7 字节) | `TextEncoder` 编码的 n-7 字节,无额外长度字段 | + +短字符串方案:类型标识本身编码了长度(8 表示 1 字节,256 表示 249 字节),`TextEncoder` 编码后直接写内容。大多数录像参数(道具 ID 名、操作描述等)落在此范围内,基本无需额外长度字段。 + +## 指令宽度确定 + +1. `IReplayArray` 构造时传入最大命令 ID(由 `IReplaySystem.registerCommand` 的累积结果提供)。若 maxId > 255,`commandWidth` 初始化为 `Uint16`;否则为 `Uint8`。 +2. 注册阶段即确定宽度,后续 `add`/`insert` 不涉及宽度切换。 +3. `setCommandWidth` 用于变更已存在录像的指令宽度:遍历全部指令条目,将原宽度的指令码按新宽度重写,更新 `metadata`。若缩小宽度时存在超过新宽度表示范围的指令 ID,应报错。 + +## 流式读取器内部状态 + +`read()` 每步仅解码当前指针指向的录像步: + +1. 从指令数组当前字节偏移读 `uint8` 参数数量,再按 `commandWidth` 读指令码。 +2. 根据参数数量,从参数数组当前字节偏移逐参数解码。 +3. 将指令数组字节偏移增加 `1 + commandWidth`,参数数组字节偏移增加已解码参数的总字节数。 +4. 返回 `IReplayStepHandler`,`index` 为当前步索引。指针推进到下一步。 + +# 涉及文件 + +## `@user/data-common/src/replay/array.ts` + +- [ ] 新增 `ReplayCommandWidth` 枚举 +- [ ] 编写 `IReplayArray`、`IReplayReadStream`、`IReplayMetadata` 接口 +- [ ] 编写 `ReplayArray` 类:实现 `IReplayArray`,封装双 `ArrayBuffer` 操作、指令宽度管理与变更、CRUD 编辑、流式读取 +- [ ] 编写 `ReplayReadStream` 类:实现 `IReplayReadStream` diff --git a/docs/dev/replay/replay-safety.md b/docs/dev/replay/replay-safety.md new file mode 100644 index 0000000..ad84a41 --- /dev/null +++ b/docs/dev/replay/replay-safety.md @@ -0,0 +1,80 @@ +# 需求综述 + +录像系统依赖作者在各逻辑系统中手动调用 `record`,这一依赖关系使得录像极易因疏忽而失效。需要一种机制来检测这类失效。 + +# 需求理解 + +## 明确需求 + +1. 录像存在失效风险。录像能否正确还原游戏流程完全取决于作者是否在每个状态修改处都调用了 `record`,遗漏是不可避免的风险。 + +## 隐含需求 + +1. 检测不能误报。系统内部的状态修改(如战斗结算中的属性计算)不应被检测为遗漏——只有从玩家操作入口派生的修改才需要检测。 +2. 播放时同样需检测。沙箱播放期间原始录像系统重新录制用于交叉验证——如果播放逻辑存在遗漏,同样应被检测到。 + +# 设计前提 + +1. 已知方案。`@shouldReplay` 标记影响状态的方法,`@ignoreReplay` 标记特定的延迟或被动代码路径,`begin`/`end` 界定检测区间。以下设计基于此方案推导。 + +# 核心概念定义 + +## 检测区间 + +由一对 `begin`/`end` 调用界定的代码执行范围。区间内收集标记,结束时比较区间前后的录像步数变化,结合标记信息决定是否告警。 + +## 状态影响标记 + +数据端方法上的 `@shouldReplay` 修饰器。表示此方法的调用应伴随录像步的写入——它是系统判断"此处有状态修改"的唯一依据。 + +## 检测抑制标记 + +特定方法上的 `@ignoreReplay` 修饰器。某些场景中,`@shouldReplay` 方法确实被调用,但录像步的写入发生在此区间之外(如异步回调中),不加抑制会误报。它不应用于所有渲染端交互函数——那样系统永不会告警。 + +# 接口设计分析 + +## 检测体系 + +### 设计思路 + +由于录像失效来源于作者忘记 `record`,而系统无法自动区分"状态修改来自玩家操作"还是"系统内部结算"——计算机不可能判断一次属性修改是玩家打怪触发的还是战斗计算过程——因此必须由作者显式标注哪些方法会影响状态。由此设计 `@shouldReplay` 修饰器。 + +标注了哪些方法会影响状态之后,还需要界定"在什么范围内检测"。一次玩家操作可能触发若干 `@shouldReplay` 方法——它们应当在同一区间内被收容,区间结束时统一评判。因此需要 `beginReplaySafetyCollection` 和 `endReplaySafetyCollection`。区间需要知道录像是否增长——`begin` 接收 `IReplaySystem` 参数以记录初始步数。 + +仅有标注和区间还不够——有些代码路径中 `@shouldReplay` 方法确实会被调用,但录像步的写入并不在当前区间内完成。例如玩家点击购买道具,购买逻辑触发属性修改(`@shouldReplay`),但对应的 `record` 在异步事件处理中延迟执行。若不加抑制,这些场景都会误报。因此需要 `@ignoreReplay` 修饰器,标记此类延迟或被动触发路径。 + +### 接口分析 + +- 修饰器 `@shouldReplay(description: string)`:预期频率**中频**。每个影响状态的方法标注一次。 +- 修饰器 `@ignoreReplay(description: string)`:预期频率**中频**。每个需抑制误报的方法标注一次。 +- 函数 `beginReplaySafetyCollection(replay: IReplaySystem): void`:预期频率**低频**。仅出现在渲染端交互入口和沙箱播放循环两处。 +- 函数 `endReplaySafetyCollection(): void`:预期频率**低频**。与 `begin` 配对。 + +### 预期体量 + +预期代码体量 50 行。 + +- 修饰器工厂各 10 行。 +- `begin`/`end` 及区间管理预期 30 行。 + +--- + +# 实现思路 + +## 检测流程 + +1. 渲染端交互入口调用 `beginReplaySafetyCollection(replay)`。 +2. 代码执行——`@shouldReplay` 和 `@ignoreReplay` 修饰的方法被调用时向区间注册标记。 +3. 调用 `endReplaySafetyCollection()`,检查:有 `@shouldReplay` 标记、无 `@ignoreReplay` 标记、步数未增 → 警告。 +4. 沙箱每步包裹 `begin`/`end`,用于交叉验证。 + +# 涉及文件 + +## `@user/data-common/src/replay/safety.ts` + +- [ ] 编写 `shouldReplay` 和 `ignoreReplay` 修饰器工厂 +- [ ] 编写 `beginReplaySafetyCollection` 和 `endReplaySafetyCollection` 函数 + +# 待确认问题 + +无。 diff --git a/docs/dev/replay/replay-system.md b/docs/dev/replay/replay-system.md new file mode 100644 index 0000000..5e4ba5a --- /dev/null +++ b/docs/dev/replay/replay-system.md @@ -0,0 +1,210 @@ +# 需求综述 + +需要一个能录制并回放游戏过程的系统,通过录像唯一还原一段游戏流程。 + +# 需求理解 + +## 明确需求 + +1. 游戏过程可回放。能通过录像唯一地还原出游戏从起点到终点的一段完整流程。 +2. 录像步可自定义。录像步的类型不限于系统内置——移动、使用道具等——也允许作者自定义。 + +## 隐含需求 + +1. 异步播放。由于播放每个录像步都伴随渲染动画,动画未结束前不能执行下一步——播放必须是异步的。 +2. 渲染端被动通知。架构约束下渲染端不能主动拉取数据——需要钩子将每步信息推送给渲染端。 +3. 录像需跨会话持久化。录像可能录制于这次游戏,在下次游戏中被播放——需要存档。 + +## 未确定需求 + +无。 + +# 设计前提 + +1. 确定性执行。计入录像的操作在相同输入下产生相同输出。 +2. 单向依赖。录像系统位于 `@user/data-common`,不依赖 Layer 1/2。渲染端被动接收信息。 +3. 外部主动录制。系统不自动录制——作者在逻辑系统中手动调用录像步写入接口。 +4. 底层存储独立。录像的二进制存储由 `IReplayArray` 统一管理(详见 `replay-array.md`),本系统不直接操作 `ArrayBuffer`。 + +# 核心概念定义 + +## 录像步 + +一段游戏流程中不可再分的最小操作单元。由一个指令标识和一组数量可变的参数构成:指令标识区分操作类型,参数描述操作的细节。 + +## 录像 + +一组按时间顺序排列的录像步的序列。完整记录从某初始状态到某最终状态的全部操作,是还原一段游戏流程的充要条件。 + +## 录像录制 + +将玩家操作转化为录像步并通过 `IReplayArray.add` 追加到末尾的过程。由作者在各逻辑系统(商店、移动等)中主动调用写入接口完成。 + +## 录像播放 + +从 `IReplayArray` 中通过流式读取器依次取出录像步并驱动数据端状态变化的过程。由于播放时需要从初始状态逐步重建到目标状态——这是整个系统中唯一需要批量修改全局状态的场景——使用沙箱隔离播放过程。 + +# 接口设计分析 + +## `IReplaySystem` + +### 设计思路 + +由于要还原游戏流程,需要记录玩家操作。不同操作对应不同录像步类型——因此需要 `registerCommand` 注册命令,使每种命令 ID 映射到对应的执行对象。 + +注册了命令类型后,运行时需要向 `IReplayArray` 中追加新步——因此需要 `record` 方法,接收命令 ID 和可变长度的参数,内部委托给 `IReplayArray.add`。 + +由于需要回放录像,且回放时原始录像系统仍需录制新步(做交叉验证),播放需要有独立的执行环境。此外播放前需要重置全局状态,但重置逻辑属于数据端不应被 Layer 0 直接依赖——因此需要 `createReplaySandbox` 创建沙箱,通过 `IStateReseter` 接口间接访问重置能力。播放结束需要销毁沙箱——因此需要 `releaseSandbox`。 + +由于播放期间渲染端和数据端需要切换行为,需要一个成员标记当前是否有活跃沙箱——因此需要 `replaying`。外部可能需要获取当前沙箱实例——因此需要 `sandbox` 成员。 + +### 接口分析 + +- 成员 `replaying: boolean`:预期频率**高频**。 +- 成员 `sandbox: IReplaySandbox | null`:预期频率**中频**。 +- 方法 `registerCommand(id: number, command: IReplayCommand)`:预期频率**低频**。 +- 方法 `record(id: number, ...params: ReplayParamValue[])`:预期频率**高频**。 +- 方法 `createReplaySandbox(reseter: IStateReseter, save?: Map): IReplaySandbox`:预期频率**低频**。 +- 方法 `releaseSandbox()`:预期频率**低频**。 + +### 预期体量 + +预期代码体量 60-80 行。 + +- 命令注册与管理预期 15 行:`Map` 的增删查。 +- 沙箱创建与销毁预期 20 行:深拷贝 `IReplayArray`、注入命令注册表和 `IStateReseter`、创建 `ReplaySandbox` 实例。 +- 存档实现预期 25 行:`saveState` 从 `IReplayArray` 导出 `ArrayBuffer` 及 `IReplayMetadata`,`loadState` 从存档数据重建 `IReplayArray` 并恢复标记。 + +## `IReplayCommand` + +### 设计思路 + +由于作者可以自定义录像步类型,播放不同步类型时需要执行不同的操作逻辑。录像系统本身无法知晓这些逻辑,需要作者提供。这些逻辑往往需要持有数据端引用,纯函数回调无法满足——因此需要接口对象 `IReplayCommand`,作者编写实现类通过构造器持有所需引用。沙箱执行时传递当前步信息——因此 `execute` 直接接收 `IReplayStepHandler`。 + +### 接口分析 + +- 方法 `execute(step: IReplayStepHandler)`:预期频率**低频**。每个命令编写一个实现类。 + +### 预期体量 + +预期代码体量 5 行。 + +## `IReplaySandbox` + +### 设计思路 + +由于需要回放录像,播放过程中需要流程控制——连续推进、暂停、单步前进、终止。因此需要 `play`、`pause`、`resume`、`step`、`stop` 五个方法。 + +由于需求隐含要求异步播放和渲染端被动通知——项目已有 `IHookable` 通用钩子系统,沙箱扩展 `IHookable`,每步触发钩子并 `await` 其完成。 + +由于播放期间原始录像系统仍在录制,沙箱不能引用正在增长的 `IReplayArray`——创建时深拷贝,播放时通过 `IReplayReadStream` 顺序读取每一步,避免每次随机访问的 O(n) 偏移扫描。播放速度需要调节,渲染端据此调整动画时长——因此需要 `speed` 只读成员和 `setSpeed` 方法。 + +### 接口分析 + +- 成员 `replaying: boolean`:预期频率**高频**。 +- 成员 `pausing: boolean`:预期频率**中频**。 +- 成员 `speed: number`:预期频率**中频**。播放倍率,只读。 +- 方法 `setSpeed(speed: number)`:预期频率**低频**。 +- 方法 `play()`:预期频率**低频**。 +- 方法 `pause()`:预期频率**低频**。 +- 方法 `resume()`:预期频率**低频**。 +- 方法 `stop()`:预期频率**低频**。 +- 方法 `step()`:预期频率**中频**。返回 `Promise`。 + +### 预期体量 + +预期代码体量 120-160 行。 + +- 播放循环预期 80 行。在循环中通过 `IReplayReadStream.read()` 逐帧解码,传给 `IReplayCommand.execute`,再触发 `onStep` 钩子并 `await`。连续模式下循环自动推进,单步模式执行一步后挂起等待下个 `step` 调用,暂停冻结循环但不丢失索引,终止跳出循环并清空流式读取器。四种模式的状态切换组合逻辑占主要行数。 +- 状态管理预期 30 行。`play`/`pause`/`resume`/`stop`/`step` 之间的互斥与合法性检查——未播放时 `pause` 无效果,已暂停时 `resume` 才恢复,`stop` 后一切操作无效。 +- 钩子集成预期 20 行。扩展 `Hookable` 后在循环中调用 `forEachHook`,收集 `onStep` 的 Promise 并全部 `await`。 + +## `IStateReseter` + +### 设计思路 + +由于沙箱播放前需将全局状态恢复到基准状态——从头播放恢复为初始状态,从存档继续播放恢复为存档状态。重置能力属于数据端,不应被 `@user/data-common` 直接依赖——因此设计 `IStateReseter` 接口隔离此依赖,`reset` 接收可选存档数据覆盖两种场景。 + +### 接口分析 + +- 方法 `reset(save?: Map)`:预期频率**低频**。 + +### 预期体量 + +预期代码体量 3 行。 + +## `IReplaySandboxHook` + +### 设计思路 + +由于架构约束下渲染端被动接收信息,沙箱需要钩子将每步信息推送给渲染端。项目已有 `IHookable`/`IHookBase` 体系——因此沙箱钩子扩展 `IHookBase`,提供 `onStep` 方法接收 `IReplayStepHandler`。沙箱 `await` 其完成后再推进下一步。 + +### 接口分析 + +- 方法 `onStep?(step: IReplayStepHandler): Promise`:预期频率**低频**。渲染端注册时定义一次。 + +### 预期体量 + +预期代码体量 4 行。 + +## `IReplayStepHandler` + +### 设计思路 + +由于钩子和命令执行器都需要当前步的完整信息(操作类型、参数、索引),因此设计统一的 handler 风格数据接口 `IReplayStepHandler`,包含 `id`、`params`、`index`。 + +### 接口分析 + +- 成员 `id: number`:预期频率**高频**。 +- 成员 `params: readonly ReplayParamValue[]`:预期频率**高频**。 +- 成员 `index: number`:预期频率**低频**。 + +### 预期体量 + +预期代码体量 5 行。 + +## 类型别名 + +`ReplayParamValue = number | string | boolean | bigint` + +--- + +# 实现思路 + +## 沙箱播放与交叉验证 + +1. 创建沙箱时深拷贝当前 `IReplayArray`。 +2. 调用 `reseter.reset(save)` 重置全局状态。 +3. 创建 `IReplayReadStream`,循环:`read()` 解码一步 → `IReplayCommand.execute` → 触发 `onStep` → `await`。 +4. 原始录像系统清空并重新录制——播放中沙箱执行的命令触发 `record`,若遗漏则安全系统警告。 + +# 涉及文件 + +## `@user/data-common/src/replay/types.ts` + +- [ ] 新增 `IReplaySystem`、`IReplaySandbox`、`IReplayCommand`、`IStateReseter`、`IReplaySandboxHook`、`IReplayStepHandler` 接口与 `ReplayParamValue` 类型别名 + +## `@user/data-common/src/replay/replay.ts` + +- [ ] 编写 `ReplaySystem` 类:命令注册、沙箱创建、存档实现 + +## `@user/data-common/src/replay/sandbox.ts` + +- [ ] 编写 `ReplaySandbox` 类:扩展 `Hookable` 实现 `IReplaySandbox` + +## `@user/data-common/src/replay/array.ts` + +- [ ] 详见 `replay-array.md` + +## `@user/data-common/src/types.ts` + +- [ ] 新增 `IDataCommon` 成员 `replay` + +## `@user/data-common/src/index.ts` + +- [ ] 新增 `export * from './replay'` + +## `@user/data-state/src/core.ts` + +- [ ] 新增 `ReplaySystem` 实例化与 `IStateReseter` 实现 +- [ ] 新增存档注册 diff --git a/docs/dev/template.md b/docs/dev/template.md index dd0446c..9be2552 100644 --- a/docs/dev/template.md +++ b/docs/dev/template.md @@ -50,7 +50,9 @@ ### 设计思路 -> 这部分应当分析需求,并写明接口是如何设计出来的,而不是简单地阐述接口内容。 +> 这部分应当分析需求,并写明接口是如何设计出来的,而不是简单地阐述接口内容。第一句必须写为什么需要设计这个接口本身,这里是 `IObjectMover` 接口,后面才能写接口的具体内容。 + +为了实现通用的物体移动功能,因此设计 `IObjectMover` 接口,包含所有物体移动最基本的接口支持。 对于玩家操控,判定往往发生在移动前,因此为统一接口,设计出 `onStepStart` 与 `onStepEnd` 两个接口,前者进行判定,后者根据判定结果进行相应的操作。为了让后者知道判定结果,将 `onStepStart` 的返回值设计为 `Promise`,`onStepEnd` 接收之并执行操作。 diff --git a/packages-user/data-common/src/index.ts b/packages-user/data-common/src/index.ts index 59e8ff3..cb9e572 100644 --- a/packages-user/data-common/src/index.ts +++ b/packages-user/data-common/src/index.ts @@ -1,4 +1,5 @@ export * from './common'; +export * from './replay'; export * from './save'; export * from './store'; diff --git a/packages-user/data-common/src/replay/array.ts b/packages-user/data-common/src/replay/array.ts new file mode 100644 index 0000000..cd4fb3c --- /dev/null +++ b/packages-user/data-common/src/replay/array.ts @@ -0,0 +1,790 @@ +import { logger } from '@motajs/common'; +import { + IReplayArray, + IReplayArrayConfig, + IReplayReadStream, + IReplayStepHandler, + ReplayCommandWidth, + ReplayParamValue +} from './types'; + +interface INormalizedParam { + /** + * 参数类型: + * + * - 0: boolean + * - 1: int8 + * - 2: int16 + * - 3: int32 + * - 4: int64 + * - 5: float + * - 6: bigint + * - 7: string + * - 8 ~ 255: n - 7 长度的字符串 + */ + readonly paramType: number; + + /** 参数值 */ + readonly paramValue: number | Uint8Array; + /** 此参数占据的字节长度,包含类型 token */ + readonly byteLength: number; +} + +interface IDecodedParam { + /** 参数值 */ + readonly paramValue: ReplayParamValue; + /** 参数占据的字节数,包含类型 token */ + readonly byteLength: number; +} + +interface IDecodedCommand { + /** 指令标识 */ + readonly command: number; + /** 指令的参数数量 */ + readonly paramCount: number; +} + +export class ReplayArray implements IReplayArray { + length: number = 0; + commandWidth: ReplayCommandWidth = ReplayCommandWidth.Uint8; + + /** 文本编码器 */ + private readonly textEncoder: TextEncoder; + /** 文本解码器 */ + private readonly textDecoder: TextDecoder; + + /** 指令数组缓冲区 */ + private commandBuffer: ArrayBuffer; + /** 参数数组缓冲区 */ + private paramBuffer: ArrayBuffer; + /** 参数起始索引缓冲区 */ + private indexBuffer: ArrayBuffer; + /** 当前参数缓冲区已经使用了多少字节 */ + private paramUsed: number; + + /** 指令数组的 DataView */ + private commandView: DataView; + /** 参数数组的 DataView */ + private paramView: DataView; + /** 指令数组的 TypedArray */ + private commandArray: Uint8Array; + /** 参数数组的 TypedArray */ + private paramArray: Uint8Array; + /** 参数起始索引 TypedArray */ + private indexArray: Uint32Array; + + /** 指令数组扩容乘数 */ + private readonly commandExpand: number; + /** 参数数组扩容乘数 */ + private readonly paramExpand: number; + /** 指令数组最大长度 */ + private readonly commandMax: number; + /** 参数数组最大长度 */ + private readonly paramMax: number; + + /** 所有可用的读取流 */ + private readonly readStreams: Set; + + constructor(config: Readonly) { + this.textEncoder = new TextEncoder(); + this.textDecoder = new TextDecoder(); + this.readStreams = new Set(); + this.paramUsed = 0; + this.commandWidth = config.commandWidth; + this.commandMax = config.commandMaxLength; + this.paramMax = config.paramMaxLength; + + if (config.commandExpandMultiplier < 1) { + const str = config.commandExpandMultiplier.toString(); + logger.warn(149, 'command', str); + this.commandExpand = 2; + } else { + this.commandExpand = config.commandExpandMultiplier; + } + + if (config.paramExpandMultiplier < 1) { + const str = config.paramExpandMultiplier.toString(); + logger.warn(149, 'param', str); + this.paramExpand = 2; + } else { + this.paramExpand = config.paramExpandMultiplier; + } + + const commandSize = this.getCommandSize(); + this.commandBuffer = new ArrayBuffer( + config.initCommandLength * commandSize + ); + this.paramBuffer = new ArrayBuffer(config.initParamLength); + this.indexBuffer = new ArrayBuffer(config.initCommandLength * 4); + + this.commandView = new DataView(this.commandBuffer); + this.paramView = new DataView(this.paramBuffer); + this.commandArray = new Uint8Array(this.commandBuffer); + this.paramArray = new Uint8Array(this.paramBuffer); + this.indexArray = new Uint32Array(this.indexBuffer); + } + + /** + * 获取指令位宽 + */ + private getCommandSize() { + if (this.commandWidth === ReplayCommandWidth.Uint8) { + return 2; + } else { + return 3; + } + } + + /** + * 检查两个数组缓冲区的容量,不足则扩容 + * @param paramLength 将要添加至参数数组的内容的字节数 + */ + private checkBufferExpand(paramLength: number) { + const commandSize = this.getCommandSize(); + const commandLength = Math.ceil( + this.commandBuffer.byteLength / commandSize + ); + + let expanded = false; + + if (commandLength - this.length < 10) { + if (commandLength >= this.commandMax) { + logger.warn(150, 'command', this.commandMax.toString()); + } else { + const nextSize = Math.min( + Math.ceil(commandLength * this.commandExpand), + this.commandMax + ); + const origin = this.commandArray; + this.commandBuffer = new ArrayBuffer(nextSize * commandSize); + this.commandView = new DataView(this.commandBuffer); + this.commandArray = new Uint8Array(this.commandBuffer); + this.commandArray.set(origin); + + const originIndex = this.indexArray; + this.indexBuffer = new ArrayBuffer(nextSize * 4); + this.indexArray = new Uint32Array(this.indexBuffer); + this.indexArray.set(originIndex); + + expanded = true; + } + } + + if (this.paramBuffer.byteLength - this.paramUsed < paramLength + 10) { + if (this.paramBuffer.byteLength >= this.paramMax) { + logger.warn(150, 'param', this.paramMax.toString()); + } else { + const nextSize = Math.min( + Math.ceil(this.paramBuffer.byteLength * this.paramExpand), + this.paramMax + ); + const origin = this.paramArray; + this.paramBuffer = new ArrayBuffer(nextSize); + this.paramView = new DataView(this.paramBuffer); + this.paramArray = new Uint8Array(this.paramBuffer); + this.paramArray.set(origin); + + expanded = true; + } + } + + if (expanded) { + // 重新检查一遍,防止拓展之后还是不够用 + this.checkBufferExpand(paramLength); + } + } + + //#region 写入与编码 + + /** + * 将所有的读取流标记为过期并删除 + */ + private expireStreams() { + this.readStreams.forEach(v => { + v.expired = true; + }); + this.readStreams.clear(); + } + + /** + * 将数值标准化为数字或 Uint8Array + * @param param 参数值 + */ + private normalizeParam(param: ReplayParamValue): INormalizedParam { + if (typeof param === 'boolean') { + // 0 - boolean + return { + paramType: 0, + paramValue: param ? 1 : 0, + byteLength: 2 + }; + } else if (typeof param === 'number') { + let type = 1; + let byte = 2; + if (Number.isInteger(param)) { + if (param < 128 && param >= -128) { + // 1 - int8 + type = 1; + byte = 2; + } else if (param < 32768 && param >= -32768) { + // 2 - int16 + type = 2; + byte = 3; + } else if (param < 2147483648 && param >= -2147483648) { + // 3 - int32 + type = 3; + byte = 5; + } else { + // 4 - int64 + type = 4; + byte = 9; + } + } else { + // 5 - float + type = 5; + byte = 9; + } + return { + paramType: type, + paramValue: param, + byteLength: byte + }; + } else if (typeof param === 'bigint') { + // 6 - bigint + const wall = 2n ** 2047n; + if (param > wall - 1n || param < -wall) { + logger.warn(152); + } + const bit = param.toString(2); + const length = Math.ceil(bit.length / 8); + const arr = new Uint8Array(length); + let total = 0n; + for (let i = 0; i < length; i++) { + const base = param - total; + const remain = base % 256n; + total += remain << (BigInt(i) * 8n); + arr[i] = Number(remain); + } + return { + paramType: 6, + paramValue: arr, + byteLength: arr.length + }; + } else if (typeof param === 'string') { + const arr = this.textEncoder.encode(param); + if (arr.length < 248) { + // 8 ~ 255 - string + return { + paramType: arr.length + 8, + paramValue: arr, + byteLength: arr.length + }; + } else { + // 7 - string + return { + paramType: 7, + paramValue: arr, + byteLength: arr.length + }; + } + } + logger.warn(148, typeof param, String(param)); + return { + paramType: 0, + paramValue: 0, + byteLength: 0 + }; + } + + /** + * 将一系列参数值标准化为数字或 Uint8Array + * @param params 参数值列表 + */ + private normalizeParamList(params: ReplayParamValue[]): INormalizedParam[] { + let arr = params; + if (params.length > 255) { + logger.warn(153, params.length.toString()); + arr = params.slice(0, 255); + } + return arr.map(v => this.normalizeParam(v)); + } + + /** + * 计算参数所需的字节数 + * @param params 标准化后的参数值 + */ + private calculateParamsLength(params: INormalizedParam[]): number { + return params.reduce((prev, curr) => prev + curr.byteLength, 0); + } + + /** + * 将指令实际写入缓冲区 + * @param startIndex 指令的起始索引 + * @param paramCount 指令对应的参数数量 + * @param command 指令的标识数字 + */ + private setCommandArray( + startIndex: number, + paramCount: number, + command: number + ) { + this.commandView.setUint8(startIndex, paramCount); + if (this.commandWidth === ReplayCommandWidth.Uint8) { + this.commandView.setUint8(startIndex + 1, command); + } else { + this.commandView.setUint16(startIndex + 1, command); + } + } + + /** + * 将参数列表实际写入缓冲区 + * @param startIndex 起始索引 + * @param params 参数列表 + */ + private setParamArray(startIndex: number, params: INormalizedParam[]) { + params.forEach(param => { + this.paramView.setInt8(startIndex, param.paramType); + + const num = param.paramValue as number; + const arr = param.paramValue as Uint8Array; + + if (param.paramType === 0) { + // 0 - boolean + this.paramView.setInt8(startIndex + 1, num); + } else if (param.paramType === 1) { + // 1 - int8 + this.paramView.setInt8(startIndex + 1, num); + } else if (param.paramType === 2) { + // 2 - int16 + this.paramView.setInt16(startIndex + 1, num); + } else if (param.paramType === 3) { + // 3 - int32 + this.paramView.setInt32(startIndex + 1, num); + } else if (param.paramType === 4) { + // 4 - int64 + const high = Math.floor(num / 2147483648); + const low = num % 2147483648; + this.paramView.setInt32(startIndex + 1, low); + this.paramView.setInt32(startIndex + 5, high); + } else if (param.paramType === 5) { + // 5 - float + this.paramView.setFloat64(startIndex + 1, num); + } else if (param.paramType === 6) { + // 6 - bigint + this.paramArray[startIndex + 1] = arr.length; + this.paramArray.set(arr, startIndex + 2); + } else if (param.paramType === 7) { + // 7 - string + this.paramView.setInt32(startIndex + 1, arr.length); + this.paramArray.set(arr, startIndex + 5); + } else { + // 8 ~ 256 - string + this.paramArray.set(arr, startIndex + 1); + } + }); + } + + add(command: number, params: ReplayParamValue[]): void { + const normalized = this.normalizeParamList(params); + const length = this.calculateParamsLength(normalized); + this.checkBufferExpand(length); + + // 追加指令 + const commandSize = this.getCommandSize(); + const commandStart = commandSize * this.length; + this.setCommandArray(commandStart, params.length, command); + + // 追加参数 + this.setParamArray(this.paramUsed, normalized); + this.indexArray[this.length] = this.paramUsed; + this.paramUsed += length; + this.length++; + + this.expireStreams(); + } + + insert(index: number, command: number, params: ReplayParamValue[]): void { + const normalized = this.normalizeParamList(params); + const length = this.calculateParamsLength(normalized); + this.checkBufferExpand(length); + + const commandSize = this.getCommandSize(); + + // 先进行位移 + const commandStart = index * commandSize; + const paramStart = this.indexArray[index]; + this.commandArray.copyWithin(commandStart + commandSize, commandStart); + this.paramArray.copyWithin(paramStart, paramStart + length); + this.indexArray.copyWithin(index, index + 1); + + // 然后进行赋值操作,索引数组因为这一个指令的起始索引其实没变,所以不需要赋值 + this.setCommandArray(commandStart, params.length, command); + this.setParamArray(paramStart, normalized); + + this.length++; + this.paramUsed += length; + + // 最后把后面的内容的索引数组增加 length 即可 + for (let i = index + 1; i < this.length; i++) { + this.indexArray[i] += length; + } + + this.expireStreams(); + } + + delete(index: number): void { + const commandSize = this.getCommandSize(); + const commandStart = index * commandSize; + const paramStart = this.indexArray[index]; + const nextParam = this.indexArray[index + 1]; + const paramLength = nextParam - paramStart; + + // 直接进行位移 + this.commandArray.copyWithin(commandStart, commandStart + commandSize); + this.paramArray.copyWithin(paramStart, nextParam); + this.indexArray.copyWithin(index, index + 1); + + this.length--; + this.paramUsed -= paramLength; + + // 然后把后续值赋零 + const commandLast = this.length * commandSize; + const paramLast = this.paramUsed; + this.commandArray[commandLast] = 0; + this.commandArray[commandLast + 1] = 0; + this.commandArray[commandLast + 2] = 0; + for (let i = 0; i < paramLength; i++) { + this.paramArray[paramLast + i] = 0; + } + + // 最后把后面的索引减少 paramLength + for (let i = paramStart; i < this.length; i++) { + this.indexArray[i] -= paramLength; + } + + this.expireStreams(); + } + + set(index: number, command: number, params: ReplayParamValue[]): void { + const normalized = this.normalizeParamList(params); + const length = this.calculateParamsLength(normalized); + const paramStart = this.indexArray[index]; + const nextParam = this.indexArray[index + 1]; + const paramLength = nextParam - paramStart; + const deltaLength = length - paramLength; + if (deltaLength > 0) { + this.checkBufferExpand(deltaLength); + } + + // 先写入指令 + const commandSize = this.getCommandSize(); + const commandStart = index * commandSize; + this.setCommandArray(commandStart, params.length, command); + + // 然后根据差值位移参数数组,如果参数长度减少还需要将最后几项置零 + this.paramArray.copyWithin(nextParam + deltaLength, nextParam); + this.paramUsed += deltaLength; + if (deltaLength < 0) { + const zeros = -deltaLength; + const paramLast = this.paramUsed; + for (let i = 0; i < zeros; i++) { + this.paramArray[paramLast + i] = 0; + } + } + + // 然后设置参数数组 + this.setParamArray(paramStart, normalized); + + // 最后调整索引数组 + for (let i = paramStart + 1; i < this.length; i++) { + this.indexArray[i] += deltaLength; + } + + this.expireStreams(); + } + + setCommandWidth(width: ReplayCommandWidth): void { + const oldWidth = this.commandWidth; + const oldSize = this.getCommandSize(); + this.commandWidth = width; + const newSize = this.getCommandSize(); + const commandLength = this.commandBuffer.byteLength / oldSize; + const length = Math.floor(commandLength * newSize); + const newBuffer = new ArrayBuffer(length); + const newView = new DataView(newBuffer); + + for (let i = 0; i < this.length; i++) { + const oldStart = i * oldSize; + const newStart = i * newSize; + + // 先读取旧指令 + const count = this.commandView.getUint8(oldStart); + let oldCommand = 0; + if (oldWidth === ReplayCommandWidth.Uint8) { + oldCommand = this.commandView.getUint8(oldStart + 1); + } else { + oldCommand = this.commandView.getUint16(oldStart + 1); + } + + // 再写入新的 DataView + newView.setUint8(newStart, count); + if (width === ReplayCommandWidth.Uint8) { + if (oldCommand > 255) { + logger.warn(154, oldCommand.toString()); + } + newView.setUint8(newStart + 1, oldCommand); + } else { + newView.setUint16(newStart + 1, oldCommand); + } + } + + this.commandBuffer = newBuffer; + this.commandArray = new Uint8Array(newBuffer); + this.commandView = newView; + + this.expireStreams(); + } + + //#endregion + + //#region 读取与解码 + + /** + * 解码单个录像指令 + * @param startIndex 解码起始索引 + */ + private decodeCommand(startIndex: number): IDecodedCommand { + const count = this.commandView.getUint8(startIndex); + let command = 0; + if (this.commandWidth === ReplayCommandWidth.Uint8) { + command = this.commandView.getUint8(startIndex + 1); + } else { + command = this.commandView.getUint16(startIndex + 1); + } + + return { + command, + paramCount: count + }; + } + + /** + * 解码单个录像参数 + * @param startIndex 解码起始索引 + */ + private decodeParam(startIndex: number): IDecodedParam { + const type = this.paramArray[startIndex]; + + let value: ReplayParamValue = 0; + let byte = 0; + + if (type === 0) { + // 0 - boolean + const next = this.paramArray[startIndex + 1]; + byte = 2; + if (next === 1) { + value = true; + } else if (next === 0) { + value = false; + } else { + logger.warn(151, next.toString()); + value = false; + } + } else if (type === 1) { + // 1 - int8 + byte = 2; + value = this.paramView.getInt8(startIndex + 1); + } else if (type === 2) { + // 2 - int16 + byte = 3; + value = this.paramView.getInt16(startIndex + 1); + } else if (type === 3) { + // 3 - int32 + byte = 5; + value = this.paramView.getInt32(startIndex + 1); + } else if (type === 4) { + // 4 - int64 + const low = this.paramView.getInt32(startIndex + 1); + const high = this.paramView.getInt32(startIndex + 5); + byte = 9; + value = low + high * 2147483647; + } else if (type === 5) { + // 5 - float + byte = 9; + value = this.paramView.getFloat64(startIndex + 1); + } else if (type === 6) { + // 6 - bigint + const length = this.paramView.getInt8(startIndex + 1); + let base = 0n; + for (let i = 0; i < length; i++) { + const num = this.paramView.getInt8(startIndex + 2 + i); + base += BigInt(num) << (8n * BigInt(i)); + } + byte = length + 2; + value = base; + } else if (type === 7) { + // 7 - string + const length = this.paramView.getInt32(startIndex + 1); + const endIndex = startIndex + 5 + length; + const arr = this.paramArray.slice(startIndex + 5, endIndex); + byte = length + 2; + value = this.textDecoder.decode(arr); + } else { + // 8 ~ 255 - string + const length = type - 7; + const endIndex = startIndex + 1 + length; + const arr = this.paramArray.slice(startIndex + 5, endIndex); + byte = length + 1; + value = this.textDecoder.decode(arr); + } + + return { + paramValue: value, + byteLength: byte + }; + } + + /** + * 解码指定数量的录像参数 + * @param startIndex 解码起始索引 + * @param count 解码参数数量 + */ + private decodeParamList( + startIndex: number, + count: number + ): IDecodedParam[] { + const arr: IDecodedParam[] = []; + + let index = startIndex; + for (let i = 0; i < count; i++) { + const decoded = this.decodeParam(index); + index += decoded.byteLength; + arr.push(decoded); + } + + return arr; + } + + /** + * 创建录像步信息对象 + * @param command 指令标识 + * @param params 解码后的参数列表 + * @param index 录像索引 + */ + private createReplayStepHandler( + command: number, + params: IDecodedParam[], + index: number + ): IReplayStepHandler { + return { + command, + params: params.map(v => v.paramValue), + index + }; + } + + get(index: number): IReplayStepHandler { + const commandSize = this.getCommandSize(); + const commandStart = index * commandSize; + const paramStart = this.indexArray[index]; + + const command = this.decodeCommand(commandStart); + const params = this.decodeParamList(paramStart, command.paramCount); + + return this.createReplayStepHandler(command.command, params, index); + } + + createReadStream(startIndex: number = 0): Readonly { + const commandSize = this.getCommandSize(); + let currCommand = startIndex * commandSize; + let currParam = this.indexArray[startIndex]; + + const stream: IReplayReadStream = { + index: startIndex, + length: this.length, + expired: false, + + read: () => { + if (stream.index >= this.length) return null; + const { command, paramCount } = this.decodeCommand(currCommand); + const params = this.decodeParamList(currParam, paramCount); + stream.index++; + currCommand += commandSize; + currParam += params.reduce( + (prev, curr) => prev + curr.byteLength, + 0 + ); + const index = stream.index; + return this.createReplayStepHandler(command, params, index); + }, + + destroy: () => { + this.readStreams.delete(stream); + } + }; + + this.readStreams.add(stream); + + return stream; + } + + //#endregion + + //#region 重建方法 + + rebuildIndexArray(): void { + const commandSize = this.getCommandSize(); + let currCommand = 0; + let currParam = 0; + + // 需要对每个参数进行解码,然后 cumsum + for (let i = 0; i < this.length; i++) { + const { paramCount } = this.decodeCommand(currCommand); + const params = this.decodeParamList(currParam, paramCount); + this.indexArray[i] = currParam; + currCommand += commandSize; + currParam += params.reduce( + (prev, curr) => prev + curr.byteLength, + 0 + ); + } + + this.paramUsed = currParam; + } + + setReplayArray( + commandWidth: ReplayCommandWidth, + commandBuffer: ArrayBuffer, + paramBuffer: ArrayBuffer, + length: number + ): void { + this.commandWidth = commandWidth; + this.commandBuffer = commandBuffer; + this.commandArray = new Uint8Array(commandBuffer); + this.commandView = new DataView(commandBuffer); + this.paramBuffer = paramBuffer; + this.paramArray = new Uint8Array(paramBuffer); + this.paramView = new DataView(paramBuffer); + + const commandSize = this.getCommandSize(); + const commandBufferLength = Math.ceil( + commandBuffer.byteLength / commandSize + ); + this.indexBuffer = new ArrayBuffer(commandBufferLength * 4); + this.indexArray = new Uint32Array(this.indexBuffer); + + this.length = length; + + this.rebuildIndexArray(); + } + + //#endregion + + getCommandArray(): ArrayBuffer { + return this.commandBuffer; + } + + getParamArray(): ArrayBuffer { + return this.paramBuffer; + } +} diff --git a/packages-user/data-common/src/replay/index.ts b/packages-user/data-common/src/replay/index.ts new file mode 100644 index 0000000..faa2756 --- /dev/null +++ b/packages-user/data-common/src/replay/index.ts @@ -0,0 +1,2 @@ +export * from './array'; +export * from './types'; diff --git a/packages-user/data-common/src/replay/types.ts b/packages-user/data-common/src/replay/types.ts new file mode 100644 index 0000000..51525dc --- /dev/null +++ b/packages-user/data-common/src/replay/types.ts @@ -0,0 +1,332 @@ +import { IHookable, IHookBase } from '@motajs/common'; +import { ISaveableContent } from '../save'; + +export type ReplayParamValue = number | string | boolean | bigint; + +export interface IReplayStepHandler { + /** 当前步的指令标识 */ + readonly command: number; + /** 当前步的参数列表 */ + readonly params: readonly ReplayParamValue[]; + /** 当前步在录像中的索引 */ + readonly index: number; +} + +export interface IReplayCommand { + /** + * 执行当前录像步对应的操作逻辑 + * @param step 当前录像步信息 + */ + execute(step: IReplayStepHandler): Promise; +} + +export interface IReplaySandboxHook extends IHookBase { + /** + * 当录像沙箱将某一步录像操作执行完毕后触发 + * @param step 当前录像步信息 + */ + onStep?(step: IReplayStepHandler): Promise; + + /** + * 当设置播放速度时触发 + * @param speed 播放速度 + */ + onSpeedSet?(speed: number): void; + + /** + * 当开始播放时触发 + */ + onStartReplay?(): void; + + /** + * 当停止播放时触发 + */ + onStopReplay?(): void; + + /** + * 当暂停播放时触发 + */ + onPauseReplay?(): void; + + /** + * 当继续播放时触发 + */ + onResumeReplay?(): void; +} + +export interface IReplaySandbox extends IHookable { + /** 是否处于暂停状态 */ + readonly pausing: boolean; + /** 播放倍率,1 为正常速度 */ + readonly speed: number; + + /** + * 设置播放倍率 + * @param speed 播放倍率 + */ + setSpeed(speed: number): void; + + /** + * 开始连续播放 + */ + play(): void; + + /** + * 暂停播放 + */ + pause(): Promise; + + /** + * 从暂停状态恢复播放 + */ + resume(): void; + + /** + * 终止播放,恢复为正常游戏状态 + */ + stop(): Promise; + + /** + * 单步播放一步,返回的 Promise 在该步渲染完成后兑现 + */ + step(): Promise; +} + +export interface IStateReseter { + /** + * 在录像播放时,需要将游戏状态重置为初始状态或指定存档状态,此方法用于执行此重置操作。 + * @param save 可选,已加载的存档数据,不传则重置为初始状态 + */ + reset(save?: Map): void; +} + +export const enum ReplayCommandWidth { + /** 1 字节宽度,支持 0-255 的指令 ID */ + Uint8 = 1, + /** 2 字节宽度,支持 0-65535 的指令 ID */ + Uint16 = 2 +} + +export interface IReplayReadStream { + /** 当前读取位置的步索引 */ + index: number; + /** 录像总步数 */ + length: number; + /** 当前录像读取流是否已经过期,当有录像被修改时此值会变为 true */ + expired: boolean; + + /** + * 读取当前指针位置的录像步并将指针推进到下一步 + * 超出录像长度时返回 null + */ + read(): IReplayStepHandler | null; + + /** + * 摧毁此读取流,当不再使用此录像读取流时必须调用 + */ + destroy(): void; +} + +export interface IReplayArrayConfig { + /** 初始指令数组长度,以指令数量为单位 */ + initCommandLength: number; + /** 初始参数数组长度,以字节为单位 */ + initParamLength: number; + /** 指令数组扩容乘数 */ + commandExpandMultiplier: number; + /** 参数数组扩容乘数 */ + paramExpandMultiplier: number; + /** 录像指令码位宽 */ + commandWidth: ReplayCommandWidth; + /** 指令最大长度,即最多有多少个录像步 */ + commandMaxLength: number; + /** 参数数组最大长度 */ + paramMaxLength: number; +} + +export interface IReplayArray { + /** 录像中的总步数 */ + readonly length: number; + /** 录像的指令码宽度 */ + readonly commandWidth: ReplayCommandWidth; + + /** + * 向录像末尾追加一条录像步 + * @param command 指令标识 + * @param params 参数列表 + */ + add(command: number, params: ReplayParamValue[]): void; + + /** + * 在指定索引处插入一条录像步,后续录像会自动后移。 + * 由于此操作会涉及大量的内存迁移,耗时较长,因此不建议频繁调用。 + * @param index 插入位置 + * @param command 指令标识 + * @param params 参数列表 + */ + insert(index: number, command: number, params: ReplayParamValue[]): void; + + /** + * 删除指定索引处的录像步,此行为不会产生空槽。 + * 由于此操作会涉及大量的内存迁移,耗时较长,因此不建议频繁调用。 + * @param index 要删除的步索引 + */ + delete(index: number): void; + + /** + * 修改指定索引处的录像步。 + * 由于此操作会涉及大量的内存迁移,耗时较长,因此不建议频繁调用。 + * @param index 要修改的步索引 + * @param command 新的指令标识 + * @param params 新的参数列表 + */ + set(index: number, command: number, params: ReplayParamValue[]): void; + + /** + * 读取指定索引处的录像步。该操作极为缓慢,如果不是为了指定要读取的索引,不建议频繁调用此方法。 + * @param index 步索引 + */ + get(index: number): IReplayStepHandler; + + /** + * 创建一个流式读取器,用于顺序播放 + * @param startIndex 起始步索引,默认为 0 + */ + createReadStream(startIndex?: number): Readonly; + + /** + * 变更所有已写入指令的编码宽度 + * @param width 新的指令码宽度 + */ + setCommandWidth(width: ReplayCommandWidth): void; + + /** + * 获取指令数组,为内部存储的直接引用 + */ + getCommandArray(): ArrayBuffer; + + /** + * 获取参数数组,为内部存储的直接引用。参数类型列表: + * + * - 0: boolean + * - 1: int8 + * - 2: int16 + * - 3: int32 + * - 4: int64 + * - 5: float + * - 6: bigint + * - 7: string + * - 8 ~ 255: n - 7 长度的字符串 + */ + getParamArray(): ArrayBuffer; + + /** + * 重建索引数组,索引数组用于存储每个指令的参数起始索引,速度非常慢,一般情况下不需要手动调用此接口 + */ + rebuildIndexArray(): void; + + /** + * 直接设置录像数组 + * @param commandWidth 指令位宽 + * @param commandBuffer 指令数组缓冲区 + * @param paramBuffer 参数数组缓冲区 + * @param length 录像长度 + */ + setReplayArray( + commandWidth: ReplayCommandWidth, + commandBuffer: ArrayBuffer, + paramBuffer: ArrayBuffer, + length: number + ): void; +} + +export interface IReplaySystemHook extends IHookBase { + /** + * 当创建新的录像沙盒时触发 + * @param sandbox 创建的录像沙盒 + */ + onCreateSandbox?(sandbox: IReplaySandbox): void; + + /** + * 当录像系统记录新指令时触发 + * @param command 指令标识 + * @param index 新记录的指令的索引 + * @param params 指令对应的参数 + */ + onRecordCommand?( + command: number, + index: number, + params: ReplayParamValue[] + ): void; +} + +export interface IReplayMetadataSave { + /** 当前录像使用的指令码宽度 */ + readonly commandWidth: ReplayCommandWidth; +} + +export interface IReplaySystemSave { + /** + * 指令数组,在 Uint8 位宽下,两个字节为一组,第一个字节为参数数量,第二个字节为指令标识。 + * 在 Uint16 位宽下,三个字节为一组,第一个字节为参数数量,后两个字节组成的 uint16 为指令标识。 + */ + readonly commands: ArrayBuffer; + + /** + * 参数数组,由参数类型和参数值组成。参数类型占据一个字节,参数值根据类型不同占据不同的字节。 + * 参数类型列表: + * + * - 0: boolean --- 2 Byte + * - 1: int8 --- 2 Byte + * - 2: int16 --- 3 Byte + * - 3: int32 --- 5 Byte + * - 4: int64 --- 9 Byte + * - 5: float --- 9 Byte + * - 6: bigint --- n + 1 Byte, 其中 n 是 bigint 的字节数 + * - 7: string --- n + 1 Byte, 其中 n 是字符串编码后的字节数 + * - 8 ~ 255: n - 7 长度的字符串 --- n + 1 Byte, 其中 n 是字符串编码后的字节数 + */ + readonly params: ArrayBuffer; + + /** 录像元数据 */ + readonly metadata: IReplayMetadataSave; +} + +export interface IReplaySystem + extends ISaveableContent, IHookable { + /** 当前是否处在录像播放状态 */ + readonly replaying: boolean; + /** 当前正在播放的录像沙箱实例 */ + readonly sandbox: IReplaySandbox | null; + /** 当前的录像操作器,用于直接操作或读取录像数据 */ + readonly route: IReplayArray; + + /** + * 注册一个录像命令 + * @param id 命令的唯一标识 + * @param command 命令对应的执行对象 + */ + registerCommand(id: number, command: IReplayCommand): void; + + /** + * 向录像末尾追加一条录像步 + * @param id 命令标识 + * @param params 可变数量的录像参数 + */ + record(id: number, ...params: ReplayParamValue[]): void; + + /** + * 创建一个录像播放沙箱 + * @param reseter 状态重置器,用于在播放前重置游戏状态 + * @param save 可选,已加载的存档数据,用于从存档状态继续播放 + */ + createReplaySandbox( + reseter: IStateReseter, + save?: Map + ): IReplaySandbox; + + /** + * 释放当前活跃的沙箱 + */ + releaseSandbox(): void; +} diff --git a/packages/common/src/logger.json b/packages/common/src/logger.json index c194c07..226b52a 100644 --- a/packages/common/src/logger.json +++ b/packages/common/src/logger.json @@ -207,6 +207,13 @@ "144": "A hero move top implementation object binding is required for hero moving.", "145": "No tile registered for item '$1'.", "146": "No equipment with uid of $1 in hero's inventory.", - "147": "No available equipment slot to equip $1." + "147": "No available equipment slot to equip $1.", + "148": "Unknown replay param type: $1, value(stringified): $2.", + "149": "Replay $1 array expand multiplier need to be greater than 1, but got $2.", + "150": "Replay $1 array reached its max length $2. Just provide higher value if you do need more space, or check your logic to avoid add replay command repeatly.", + "151": "0 or 1 is expected for boolean replay array, but got $1.", + "152": "Bigint replay param reached its limit, min for -(2n ** 2047n), and max for 2n ** 2047n - 1n.", + "153": "Up to 255 parameters for a single replay command, but got $1. Overflowed parameters will be ignored.", + "154": "Cannot transfer replay command $1 from uint16 to uint8, since it's greater than 255." } } diff --git a/prompt.md b/prompt.md index 8aeb0f2..6c62a50 100644 --- a/prompt.md +++ b/prompt.md @@ -26,7 +26,7 @@ ## 修改原则 1. **最小修改**:默认不重新设计已有代码,不重构已有实现,不为「更优雅」而改变已有模式。新代码应是原有代码的自然延续。若认为已有代码存在错误,在对话中提出,不要直接修改。 -2. **文件边界**:修改文件前务必重读以确保内容最新。修改后必须在对话中说明所有改动的文件及具体内容。不得修改文档「涉及文件」节未提及的文件。 +2. **文件边界**:修改文件前务必重读以确保内容最新。修改后必须在对话中说明所有改动的文件及具体内容。不得修改文档「涉及文件」节未提及的文件。所有公共方法必须在文档中提及,不允许在代码中新增未在文档中写明的公共方法。 3. **歧义提问**:遇到任何模糊、不清晰、歧义的地方,立即向我提问,不要自行假设。若完成需求所需的接口尚不存在,立即停止实现并提出疑问,不要擅自新增接口。 ## 代码组织 @@ -45,13 +45,14 @@ ## 类型与错误处理 11. **允许类型错误**:编写代码时允许出现 TypeScript 类型错误。不要为了修复类型错误而违反上述规则或增加代码复杂度。尤其不得出现 `as` 关键字。 -12. **错误必须抛出**:任何理应报错的场景必须使用 `logger` 接口抛出错误或警告,不得使用 `return` 等方法静默处理。logger 必须分配合理的 `code`,不得复用无关 code 或使用 `0`。 +12. **错误必须抛出**:任何理应报错的场景必须使用 `logger` 接口抛出错误或警告,不得使用 `return` 等方法静默处理。`logger` 必须分配合理的 `code`,不得复用无关 code 或使用 `0`。所有场景下,`logger` 都不会影响系统和游戏的正常运行,不应抛出任何异常中断来终止运行。 13. **非空判断**:对象直接用 `if (!object)`;字面量使用 `lodash` 的 `isNil`(`if (isNil(value))`),不得使用 `if (value === undefined)` 等方式。 14. **公共方法**:不得出现仅在类中定义的公共方法,所有公共方法必须先在接口中定义,然后在类中使用 `implements` 实现。 +15. **成员只读**:所有需要被实现为类的接口,其成员必须只读,如果有赋值需求,那么应使用相应的方法来完成赋值操作。 ## 架构约束 -15. **渲染端被动**:任何情况下渲染端不会主动向数据端推送更新。渲染端仅通过钩子与数据端通信,被动获取信息,仅在某些情况下通过钩子影响数据端行为。 +16. **渲染端被动**:任何情况下渲染端不会主动向数据端推送更新。渲染端仅通过钩子与数据端通信,被动获取信息,仅在某些情况下通过钩子影响数据端行为。 # 开发流程 @@ -77,7 +78,11 @@ **禁止直接从需求跳跃到接口设计。** 任何接口设计必须能够追溯到前面的需求事实和逻辑推导。 -换言之,文档是一份基于若干假设推导出的一种合理设计方案,重点在于从假设推导出设计的过程,而非设计本身。也就是说,文档本身是一道数学推理题,从公理(设计前提)和定义(核心概念定义)出发,通过逻辑推理(设计思路)得出结论(接口分析),每个环节都依赖于前面的环节,最终得出的接口设计需要从逻辑上使人信服。 +换言之,文档是一份基于若干假设推导出的一种合理设计方案,重点在于从假设推导出设计的过程,而非设计本身。也就是说,文档本身是一道数学推理题,从公理(设计前提)和定义(核心概念定义)出发,通过逻辑推理(设计思路)得出结论(接口分析),每个环节都依赖于前面的环节,最终得出的接口设计需要从逻辑上使人信服。所有的章节最核心的关键词就是**为什么**,每个章节必须写明**为什么会这样**,如果阅读文档后,我仍然不知道"为什么会设计出这个接口",那么这份文档就是失败的。 + +同时,任何时候都不能写一个接口能干什么事,这个信息对我来说没有任何价值,有价值的是从需求推导出接口设计的逻辑推理过程,也就是**文档写的是为什么**,**写是什么没有任何意义**。 + +**绝对注意**:我发给你的文字并不一定全是需求,可能会有很多内容是我经过思考之后得出的结论,这些内容不应当处理为需求,你应该通过需求建立一个合理的逻辑链来得出我给出的结论。典型的结论性语句就是,需要设计什么什么接口、因为什么什么所以怎么怎么样等等。真正的需求应该可以用几个非常简洁的点总结出来。 # 文档结构 @@ -87,7 +92,7 @@ - 文档控制在 100-250 行,简洁但包含所有必要信息;不擅自修改示例文档格式。 - 一般不需要流程图;若必须使用 `mermaid`。 - 示例文档参考 `docs/dev/template.md`,务必认真阅读后再编写文档。一切行文思路严格按照示例文档走,不要按照自己的想法修改。已编写完成的开发文档可能不符合示例文档要求或内容已过时,不要参考。 -- 不得出现大片的 `inline-code`,不是不能写,而是不能大片地出现。接口名等情况正常使用即可。 +- 不得出现大片的 `inline-code`,不是不能写,而是不能大片地出现。像接口名等情况正常使用即可。这一条的目的不是为了不让你用 `inline-code`,而是为了让你不去在文档中简单地罗列一堆接口,接口名、方法名等正常情况完全鼓励使用。 - 关于行数和百分比的约束只是帮助你理解文档中哪些内容是重点,哪些不是,并不需要严格遵循,只要重点正确即可。 ```md @@ -117,28 +122,28 @@ # 核心概念定义 -详细描述涉及本次任务的核心概念。我会提供若干个需要解释的概念,如有需要可自行添加。这部分必须使用严谨的逻辑阐述,不能是简单一两句话。 +详细描述涉及本次任务的核心概念。我会提供若干个需要解释的概念,如有需要可自行添加。这部分必须使用严谨的逻辑阐述,不能是简单一两句话。使用总分的结构描述,先用一句话给出定义“这个概念是什么”,然后再详细解释。 # 接口设计分析 -我可能不会给出完整接口设计,你需要自行分析需求补充设计,并在对话中明确指出哪些接口是你补充的。 +我可能不会给出完整接口设计,你需要自行分析需求补充设计,并在对话中明确指出哪些接口是你补充的。这一章最核心的就是为什么,必须让我知道你为什么要这么设计接口,接口频率为什么是这么多,预期体量为什么是这么大。 ## IExample ### 设计思路 -分析需求后编写接口的设计思路。重点是分析需求并说明接口是如何设计出来的,而非简单阐述接口内容。这部分是从设计前提和概念定制推导出接口分析的过程,必须详细分析。写的时候,必须有因有果,由于什么需求,所以要设计哪些接口,不得直接说某个接口可以干什么,不得直接说某个接口可以用于哪些场景。 +分析需求后编写接口的设计思路。重点是分析需求并说明接口是如何设计出来的,而非简单阐述接口内容。这部分是从设计前提和概念定制推导出接口分析的过程,必须详细分析。写的时候,必须有因有果,由于什么需求,所以要设计哪些接口,不得直接说某个接口可以干什么,不得直接说某个接口可以用于哪些场景。只说某个接口的作用没有任何意义,只有从定义和设计前提推导出设计的这个推导过程有意义,所以不要分析接口可以干什么。对于每个接口,必须写明为什么需要这个接口,“由于什么什么需求,因此需要设计什么什么接口”。当我看完后,必须得让我知道为什么要这么设计接口,如果我看完之后还是不知道为什么,那么这个章节写的就是失败的。 ### 接口分析 -按接口逐一分析成员与方法的预期使用频率。频率分为高 / 中 / 低,指**用户编写此调用的频率**,而非运行时频率或引擎调用频率。使用频率越高,名称长度宜越短。对于成员,必须写明其类型,对于方法,必须写明其参数及类型。接口分析不要包含继承而来的接口,只分析接口本身包含的内容。 +按接口逐一分析成员与方法的预期使用频率。频率分为高 / 中 / 低,指**用户编写此调用的频率**,而非运行时频率或引擎调用频率。使用频率越高,名称长度宜越短。对于成员,必须写明其类型,对于方法,必须写明其参数及类型。接口分析不要包含继承而来的接口,只分析接口本身包含的内容。必须写明为什么预期频率是这么多,如果我看完之后依然没办法知道为什么,说明这个章节写的是失败的。 - 成员 `property: type`:预期频率**高频**。进行分析。 - 方法 `method(param1: type1, ...)`:预期频率**中频**。进行分析。 ### 预期体量 -写出预期的代码体量并分析原因。不得只写一句预期多少行,必须详细分析,分条阐述。这里不是要实现思路,而是要写为什么某个功能需要这么多行。预期是对代码量的预估,目的是让我了解到你对某个功能复杂度的理解是否正确,不需要非常精确。预期体量是对实现复杂度的预期,而不是对接口定义的预期,因此不要写接口定义预期多少行。 +写出预期的代码体量并分析原因。不得只写一句预期多少行,必须详细分析,分条阐述。这里不是要实现思路,而是要写为什么某个功能需要这么多行。预期是对代码量的预估,目的是让我了解到你对某个功能复杂度的理解是否正确,不需要非常精确。预期体量是对实现复杂度的预期,而不是对接口定义的预期,因此不要写接口定义预期多少行。当我读完这个章节后,我必须得理解你为什么会认为某个功能需要这么多行,如果不行,说明这个章节写的是失败的。 - 功能一预期 100 行:进行分析。 - 功能二预期 50 行:进行分析。 @@ -171,11 +176,11 @@ ### `@motajs/package/[folder/]file.ts` -… +... # 待确认问题 -如果描述中有歧义或模糊之处,在此列出。提问必须是以下类型之一: +如果描述中有歧义或模糊之处,在此列出,此处只允许列出设计相关的问题,不允许列出实现相关的问题。提问必须是以下类型之一: - **未定义概念**:我没有明确说明某一个名词具体指什么 - **规则冲突**:我的描述中前后产生矛盾 diff --git a/review.md b/review.md new file mode 100644 index 0000000..6b9e1a7 --- /dev/null +++ b/review.md @@ -0,0 +1,13 @@ +# 核心职责 + +你的职责不是辅助我进行架构设计,也不是进行系统代码编写,而是对我写出的代码进行 review,以及测试用例的编写工作。 + +# Code Review + +在 code review 期间,你需要阅读我写出的代码,以及我给出的需求说明,提出我的代码中可能不合理的部分,我会针对这些问题给出回答,或修改相应的代码,执行几轮循环。期间不得修改我写出的代码。 + +在 review 期间,务必留意 @dev.md 中的代码规范要求,如果出现了某些不合规范的地方,需要在对话中提出,尤其注意 `as` 关键字、方法排序等要求,重点关注写有“不建议”、“建议”、“最好”、“尽量”等字样的规则。 + +# 测试用例 + +由于系统构建的不完全,目前还没有推进到需要进行单元测试的环节。