diff --git a/packages-user/data-common/src/replay/array.test.ts b/packages-user/data-common/src/replay/array.test.ts index 3ce3db9..e36565f 100644 --- a/packages-user/data-common/src/replay/array.test.ts +++ b/packages-user/data-common/src/replay/array.test.ts @@ -167,6 +167,25 @@ function expectRouteStream( expect(stream.index).toBe(expectedSteps.length); } +// 执行越界编辑并对比前后全部可观测状态:长度、两个缓冲区字节与每一步解码结果,返回本次告警码 +function expectArrayUnchanged(array: ReplayArray, run: () => void): number[] { + const length = array.length; + const commands = new Uint8Array(array.getCommandArray()).slice(); + const params = new Uint8Array(array.getParamArray()).slice(); + const steps = Array.from({ length }, (_, i) => array.get(i)); + + const { info } = logger.catch(run); + + expect(array.length).toBe(length); + expect(new Uint8Array(array.getCommandArray())).toEqual(commands); + expect(new Uint8Array(array.getParamArray())).toEqual(params); + expect( + Array.from({ length: array.length }, (_, i) => array.get(i)) + ).toEqual(steps); + + return info.map(v => v.code!); +} + describe('ReplayArray single operations', () => { // 验证 add 追加一条录像步并在原索引读回指令与参数 it('appends one step and reads it back at the original index', () => { @@ -357,6 +376,76 @@ describe('ReplayArray set index maintenance', () => { }); }); +describe('ReplayArray index bounds', () => { + // 验证 insert 传入当前总步数时按末尾追加处理,追加后按索引与读流读回结果一致 + it('appends when the insert index equals the current length', () => { + const array = createArray(); + array.add(1, [10]); + array.add(2, [20]); + + array.insert(array.length, 3, [30]); + + expect(array.length).toBe(3); + expect(array.get(2)).toEqual({ command: 3, params: [30], index: 2 }); + const stream = array.createReadStream(0); + expectStepTyped(stream.read()!, 1, [10], 1); + expectStepTyped(stream.read()!, 2, [20], 2); + expectStepTyped(stream.read()!, 3, [30], 3); + expect(stream.read()).toBeNull(); + }); + + // 验证 insert 负索引与超出总步数的索引触发告警码 179 且不修改任何缓冲区内容 + it('warns code 179 and keeps the buffers unchanged for an out-of-range insert', () => { + const array = createHeterogeneousArray(); + + for (const index of [-1, array.length + 1]) { + const codes = expectArrayUnchanged(array, () => + array.insert(index, 9, [true, 5]) + ); + expect(codes).toContain(179); + } + }); + + // 验证 delete 负索引与等于总步数的索引触发告警码 179 且不修改任何缓冲区内容 + it('warns code 179 and keeps the buffers unchanged for an out-of-range delete', () => { + const array = createHeterogeneousArray(); + + for (const index of [-1, array.length]) { + const codes = expectArrayUnchanged(array, () => + array.delete(index) + ); + expect(codes).toContain(179); + } + }); + + // 验证 set 负索引与等于总步数的索引触发告警码 179 且不修改任何缓冲区内容 + it('warns code 179 and keeps the buffers unchanged for an out-of-range set', () => { + const array = createHeterogeneousArray(); + + for (const index of [-1, array.length]) { + const codes = expectArrayUnchanged(array, () => + array.set(index, 9, [true, 5]) + ); + expect(codes).toContain(179); + } + }); + + // 验证空录像上的 delete 与 set 一律按越界处理,既不告警 179 之外的内容也不产生录像步 + it('treats every index as out of range on an empty route', () => { + const array = createArray(); + + for (const index of [0, -1]) { + expect( + expectArrayUnchanged(array, () => array.delete(index)) + ).toContain(179); + } + expect( + expectArrayUnchanged(array, () => array.set(0, 1, [10])) + ).toContain(179); + expect(array.length).toBe(0); + }); +}); + describe('ReplayArray param codec', () => { // 验证 boolean 参数按 type 0 写入并读回布尔值 it('round-trips boolean parameters', () => { diff --git a/packages-user/data-common/src/replay/array.ts b/packages-user/data-common/src/replay/array.ts index 5ade216..65dd381 100644 --- a/packages-user/data-common/src/replay/array.ts +++ b/packages-user/data-common/src/replay/array.ts @@ -436,14 +436,36 @@ export class ReplayArray implements IReplayArray { insert(index: number, command: number, params: ReplayParamValue[]): void { if (this.disabled > 0) return; + if (index < 0 || index > this.length) { + logger.warn( + 179, + 'insert', + index.toString(), + this.length.toString() + ); + return; + } const normalized = this.normalizeParamList(params); const length = this.calculateParamsLength(normalized); this.checkBufferExpand(length); const commandSize = this.getCommandSize(); + const commandStart = index * commandSize; + + // 插入到末尾等同于追加,此时无需任何位移 + if (index === this.length) { + this.setCommandArray(commandStart, params.length, command); + this.setParamArray(this.paramUsed, normalized); + this.indexArray[this.length] = this.paramUsed; + + this.length++; + this.paramUsed += length; + + this.expireStreams(); + return; + } // 先进行位移 - const commandStart = index * commandSize; const paramStart = this.indexArray[index]; this.commandArray.copyWithin(commandStart + commandSize, commandStart); this.paramArray.copyWithin(paramStart + length, paramStart); @@ -466,6 +488,15 @@ export class ReplayArray implements IReplayArray { delete(index: number): void { if (this.disabled > 0) return; + if (index < 0 || index >= this.length) { + logger.warn( + 179, + 'delete', + index.toString(), + this.length.toString() + ); + return; + } const commandSize = this.getCommandSize(); const commandStart = index * commandSize; const range = this.getParamRange(index); @@ -501,6 +532,10 @@ export class ReplayArray implements IReplayArray { set(index: number, command: number, params: ReplayParamValue[]): void { if (this.disabled > 0) return; + if (index < 0 || index >= this.length) { + logger.warn(179, 'set', index.toString(), this.length.toString()); + return; + } const normalized = this.normalizeParamList(params); const length = this.calculateParamsLength(normalized); // 末步的参数终点取 paramUsed,与 delete 同口径,避免读到未初始化的 indexArray[index + 1] @@ -825,6 +860,9 @@ export class ReplayArray implements IReplayArray { this.length = length; this.rebuildIndexArray(); + + // 缓冲区被整体替换后,此前创建的读流偏移已失效,需要与其它写路径一样标记过期 + this.expireStreams(); } //#endregion diff --git a/packages-user/data-common/src/replay/saveLoad.test.ts b/packages-user/data-common/src/replay/saveLoad.test.ts index e86cee5..3f19acc 100644 --- a/packages-user/data-common/src/replay/saveLoad.test.ts +++ b/packages-user/data-common/src/replay/saveLoad.test.ts @@ -1,4 +1,5 @@ -// 测试录像存读档:ReplaySystem 同实例往返与多类型参数读回 +// 测试录像存读档:ReplaySystem 同实例往返、多类型参数读回与活跃读流跨读档过期 +import { logger } from '@motajs/common'; import { afterAll, describe, expect, it, vi } from 'vitest'; import { ReplaySystem } from './system'; @@ -63,4 +64,24 @@ describe('ReplaySystem save and load round trips', () => { expect(system.route.getParamArray()).toBe(snapshot.paramArray); expect(system.route.get(0).command).toBe(2); }); + + // 验证读档整体替换缓冲区后,此前创建的活跃沙箱读流被标记过期并触发告警码 156(#06-17-3) + it('expires an active sandbox read stream when a snapshot is loaded', () => { + const system = createSystem(); + system.registerCommand(1, { execute: async () => true }); + system.record(1, 7); + + const snapshot = system.saveState(); + system.record(1, 9); + + const sandbox = system.createReplaySandbox({ + route: system.route, + reseter: { reset: () => {} } + }); + system.loadState(snapshot); + + const { info } = logger.catch(() => sandbox.play()); + + expect(info.map(v => v.code)).toContain(156); + }); }); diff --git a/packages-user/data-common/src/replay/types.ts b/packages-user/data-common/src/replay/types.ts index 7aba39e..cd94971 100644 --- a/packages-user/data-common/src/replay/types.ts +++ b/packages-user/data-common/src/replay/types.ts @@ -196,8 +196,10 @@ export interface IReplayArray { /** * 在指定索引处插入一条录像步,后续录像会自动后移。 + * 索引可传当前总步数,此时等同于在末尾追加一条录像步。 + * 索引越界时不会产生任何修改,并触发告警码 179。 * 由于此操作会涉及大量的内存迁移,耗时较长,因此不建议频繁调用。 - * @param index 插入位置 + * @param index 插入位置,合法区间为 `[0, length]` * @param command 指令标识 * @param params 参数列表 */ @@ -205,16 +207,18 @@ export interface IReplayArray { /** * 删除指定索引处的录像步,此行为不会产生空槽。 + * 索引越界时不会产生任何修改,并触发告警码 179。 * 由于此操作会涉及大量的内存迁移,耗时较长,因此不建议频繁调用。 - * @param index 要删除的步索引 + * @param index 要删除的步索引,合法区间为 `[0, length)` */ delete(index: number): void; /** * 修改指定索引处的录像步。新的参数列表允许与原有参数编码长度不同, * 此情形下其后所有录像步的参数起始索引会同步平移。 + * 索引越界时不会产生任何修改,并触发告警码 179。 * 由于此操作会涉及大量的内存迁移,耗时较长,因此不建议频繁调用。 - * @param index 要修改的步索引 + * @param index 要修改的步索引,合法区间为 `[0, length)` * @param command 新的指令标识 * @param params 新的参数列表 */ diff --git a/packages/common/src/logger.json b/packages/common/src/logger.json index 73cb4dc..14b8b74 100644 --- a/packages/common/src/logger.json +++ b/packages/common/src/logger.json @@ -253,6 +253,7 @@ "176": "Cannot add move step to replay system since only 'up,right,down,left' directions are acceptable.", "177": "Save data for saveable content $1 is needed, but there's no data in save data. Maybe a version issue for it? System will try to load other contents to make game run normally.", "178": "Save data with keys of '$1' are saved but not be loaded, is there some issue for it?", + "179": "Replay $1 received an out-of-range index $2, the current replay length is $3. The operation will be ignored.", "1001": "Event(setBlock): Unknown tile '$1' when setting block." } }