diff --git a/packages-user/data-common/src/replay/array.ts b/packages-user/data-common/src/replay/array.ts index 989e85c..6fe0918 100644 --- a/packages-user/data-common/src/replay/array.ts +++ b/packages-user/data-common/src/replay/array.ts @@ -272,23 +272,23 @@ export class ReplayArray return { paramType: 6, paramValue: arr, - byteLength: arr.length + byteLength: arr.length + 2 }; } else if (typeof param === 'string') { const arr = this.textEncoder.encode(param); - if (arr.length < 248) { + if (arr.length > 0 && arr.length <= 248) { // 8 ~ 255 - string return { - paramType: arr.length + 8, + paramType: arr.length + 7, paramValue: arr, - byteLength: arr.length + byteLength: arr.length + 1 }; } else { // 7 - string return { paramType: 7, paramValue: arr, - byteLength: arr.length + byteLength: arr.length + 5 }; } } @@ -346,45 +346,47 @@ export class ReplayArray * @param params 参数列表 */ private setParamArray(startIndex: number, params: INormalizedParam[]) { + let index = startIndex; params.forEach(param => { - this.paramView.setInt8(startIndex, param.paramType); + this.paramView.setInt8(index, 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); + this.paramView.setInt8(index + 1, num); } else if (param.paramType === 1) { // 1 - int8 - this.paramView.setInt8(startIndex + 1, num); + this.paramView.setInt8(index + 1, num); } else if (param.paramType === 2) { // 2 - int16 - this.paramView.setInt16(startIndex + 1, num); + this.paramView.setInt16(index + 1, num); } else if (param.paramType === 3) { // 3 - int32 - this.paramView.setInt32(startIndex + 1, num); + this.paramView.setInt32(index + 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); + this.paramView.setInt32(index + 1, low); + this.paramView.setInt32(index + 5, high); } else if (param.paramType === 5) { // 5 - float - this.paramView.setFloat64(startIndex + 1, num); + this.paramView.setFloat64(index + 1, num); } else if (param.paramType === 6) { // 6 - bigint - this.paramArray[startIndex + 1] = arr.length; - this.paramArray.set(arr, startIndex + 2); + this.paramArray[index + 1] = arr.length; + this.paramArray.set(arr, index + 2); } else if (param.paramType === 7) { // 7 - string - this.paramView.setInt32(startIndex + 1, arr.length); - this.paramArray.set(arr, startIndex + 5); + this.paramView.setInt32(index + 1, arr.length); + this.paramArray.set(arr, index + 5); } else { // 8 ~ 256 - string - this.paramArray.set(arr, startIndex + 1); + this.paramArray.set(arr, index + 1); } + index += param.byteLength; }); } @@ -631,13 +633,13 @@ export class ReplayArray const length = this.paramView.getInt32(startIndex + 1); const endIndex = startIndex + 5 + length; const arr = this.paramArray.slice(startIndex + 5, endIndex); - byte = length + 2; + byte = length + 5; 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); + const arr = this.paramArray.slice(startIndex + 1, endIndex); byte = length + 1; value = this.textDecoder.decode(arr); } diff --git a/packages-user/data-state/test/coreNode.test.ts b/packages-user/data-state/test/coreNode.test.ts new file mode 100644 index 0000000..b62d6a2 --- /dev/null +++ b/packages-user/data-state/test/coreNode.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest'; +import { SaveCompression } from '@user/data-common'; +import { createCoreState } from '../src/core'; + +describe('Node CoreState factory', () => { + // 验证无参数工厂创建的状态实例不会共享勇士、地图、事件存储和存档系统 + it('creates independent mutable data-side instances', () => { + const first = createCoreState(); + const second = createCoreState(); + + expect(first).not.toBe(second); + expect(first.hero).not.toBe(second.hero); + expect(first.hero.attribute).not.toBe(second.hero.attribute); + expect(first.eventStore).not.toBe(second.eventStore); + expect(first.saveSystem).not.toBe(second.saveSystem); + + first.hero.getModifiableAttribute().set('hp', 99); + expect(second.hero.attribute.getFinalAttribute('hp')).not.toBe(99); + + const map = first.maps.createMap('isolated-floor', 1, 1); + map.setActiveStatus(true); + const layer = map.addLayer(); + layer.setMapRef(new Uint32Array([7])); + expect(second.maps.getMap('isolated-floor')).toBeNull(); + + const saved = first.maps.saveState(SaveCompression.NoCompression); + const savedFloor = saved.floors.get('isolated-floor'); + const savedLayer = savedFloor?.layers.get(0); + if (!savedLayer?.fullMap) + throw new Error('missing isolated map snapshot'); + savedLayer.fullMap[0] = 99; + expect(layer.getBlock(0, 0)).toBe(7); + }); + + // 验证 Node 工厂路径不读取浏览器宿主并使用独立的内存存档实现 + it('constructs through the Node-safe path without browser globals', () => { + expect(() => createCoreState()).not.toThrow(); + const state = createCoreState(); + + expect(state.saveSystem.constructor.name).toBe('MemorySaveSystem'); + expect(state.maps).toBeDefined(); + expect(state.eventStore).toBeDefined(); + }); +}); diff --git a/packages-user/data-state/test/nodeReplay.test.ts b/packages-user/data-state/test/nodeReplay.test.ts new file mode 100644 index 0000000..da4bbbb --- /dev/null +++ b/packages-user/data-state/test/nodeReplay.test.ts @@ -0,0 +1,292 @@ +import { describe, expect, it } from 'vitest'; +import { IReplayCommand, ReplayParamValue } from '@user/data-common'; +import { ReplaySystem } from '../../data-common/src/replay/system'; +import { + IReplayVerifierRuntime, + IReplayVerifierSnapshot, + verifyReplay +} from '../../../script/test-data-node'; + +enum TestCommandOutcome { + Success, + False, + Throw +} + +interface ITestStep { + readonly code: number; + readonly params: readonly ReplayParamValue[]; +} + +interface ITestHarnessOptions { + readonly steps: readonly ITestStep[]; + readonly commands: ReadonlyMap; + readonly actual?: IReplayVerifierSnapshot; +} + +interface ITestHarness { + readonly runtime: IReplayVerifierRuntime; + readonly calls: number[]; + readonly finished: () => boolean; +} + +interface ITestLayerSnapshot { + readonly zIndex: number; + readonly matrix: Uint32Array; +} + +interface ITestFloorSnapshot { + readonly floorId: string; + readonly layers: readonly ITestLayerSnapshot[]; +} + +interface ITestExpectedSnapshot { + readonly hero: { + name: string; + hp: number; + hpmax: number; + atk: number; + def: number; + mdef: number; + mana: number; + manamax: number; + money: number; + exp: number; + }; + readonly maps: readonly ITestFloorSnapshot[]; +} + +interface IVerifierDiagnostic { + readonly index: number; + readonly code: number; + readonly params: readonly ReplayParamValue[]; + readonly reason: string; + readonly message: string; +} + +function createSnapshot(): ITestExpectedSnapshot { + const layer: ITestLayerSnapshot = { + zIndex: 20, + matrix: new Uint32Array([1, 2]) + }; + const floor: ITestFloorSnapshot = { + floorId: 'F1', + layers: [layer] + }; + return { + hero: { + name: '', + hp: 1, + hpmax: 0, + atk: 0, + def: 0, + mdef: 0, + mana: 0, + manamax: 0, + money: 0, + exp: 0 + }, + maps: [floor] + }; +} + +function createCommand( + outcome: TestCommandOutcome, + calls: number[], + message: string = 'test command failure' +): IReplayCommand { + return { + execute: async step => { + calls.push(step.index); + if (outcome === TestCommandOutcome.Throw) { + throw new Error(message); + } + return outcome === TestCommandOutcome.Success; + } + }; +} + +function createHarness(options: ITestHarnessOptions): ITestHarness { + const replay = new ReplaySystem(); + for (const step of options.steps) { + replay.record(step.code, ...step.params); + } + for (const [code, command] of options.commands) { + replay.registerCommand(code, command); + } + const calls: number[] = []; + const sandbox = { ended: false }; + let cursor = 0; + const runtime: IReplayVerifierRuntime = { + route: replay.route, + expected: createSnapshot(), + sandbox, + getCommand: code => replay.getCommand(code), + step: async () => { + const step = replay.route.get(cursor++); + const command = replay.getCommand(step.command); + if (!command) return false; + return command.execute(step); + }, + finish: async () => { + sandbox.ended = true; + }, + snapshot: () => options.actual ?? createSnapshot() + }; + return { + runtime, + calls, + finished: () => sandbox.ended + }; +} + +async function expectDiagnostic( + runtime: IReplayVerifierRuntime +): Promise { + let failure: unknown = null; + try { + await verifyReplay(runtime); + } catch (error) { + failure = error; + } + expect(failure).not.toBeNull(); + expect(failure).toMatchObject({ + index: expect.any(Number), + code: expect.any(Number), + params: expect.any(Array), + reason: expect.any(String) + }); + return failure as IVerifierDiagnostic; +} + +describe('Node replay verifier', () => { + // 验证固定成功路径在正常结束后比较完整勇士属性与所有地图矩阵 + it('accepts a successful replay after end-only snapshot comparison', async () => { + const calls: number[] = []; + const command = createCommand(TestCommandOutcome.Success, calls); + const harness = createHarness({ + steps: [{ code: 0, params: [] }], + commands: new Map([[0, command]]) + }); + + await verifyReplay(harness.runtime); + + expect(calls).toEqual([0]); + expect(harness.finished()).toBe(true); + }); + + // 验证未知命令在首个索引立即抛出并使用确定性安全参数展示 + it('throws the first unknown-command diagnostic without executing a step', async () => { + const harness = createHarness({ + steps: [{ code: 99, params: [1, 'safe', true, 2n] }], + commands: new Map() + }); + + const failure = await expectDiagnostic(harness.runtime); + + expect(failure.index).toBe(0); + expect(failure.code).toBe(99); + expect(failure.params).toEqual([1, 'safe', true, 2n]); + expect(failure.reason).toContain('unknown replay command code 99'); + expect(failure.message).toContain('params=[1, "safe", true, 2n]'); + expect(harness.finished()).toBe(false); + }); + + // 验证 false 结果在首个索引停止且不会执行后续录像指令 + it('throws on a false command result and stops before later commands', async () => { + const calls: number[] = []; + const first = createCommand(TestCommandOutcome.False, calls); + const second = createCommand(TestCommandOutcome.Success, calls); + const harness = createHarness({ + steps: [ + { code: 5, params: [7] }, + { code: 6, params: [] } + ], + commands: new Map([ + [5, first], + [6, second] + ]) + }); + + const failure = await expectDiagnostic(harness.runtime); + + expect(failure.index).toBe(0); + expect(failure.code).toBe(5); + expect(failure.params).toEqual([7]); + expect(failure.reason).toBe('command returned false'); + expect(calls).toEqual([0]); + expect(harness.finished()).toBe(false); + }); + + // 验证 command throw 在首个索引停止并保留原始参数与可读原因 + it('throws on a command exception and does not execute later commands', async () => { + const calls: number[] = []; + const first = createCommand(TestCommandOutcome.Throw, calls, 'boom'); + const second = createCommand(TestCommandOutcome.Success, calls); + const harness = createHarness({ + steps: [ + { code: 6, params: ['first'] }, + { code: 7, params: ['later'] } + ], + commands: new Map([ + [6, first], + [7, second] + ]) + }); + + const failure = await expectDiagnostic(harness.runtime); + + expect(failure.index).toBe(0); + expect(failure.code).toBe(6); + expect(failure.params).toEqual(['first']); + expect(failure.reason).toBe('command threw: boom'); + expect(calls).toEqual([0]); + expect(harness.finished()).toBe(false); + }); + + // 验证勇士完整属性差异只在录像正常结束后报告 + it('reports a hero snapshot mismatch only after normal end', async () => { + const calls: number[] = []; + const actual = createSnapshot(); + actual.hero.hp = 2; + const harness = createHarness({ + steps: [{ code: 0, params: [] }], + commands: new Map([ + [0, createCommand(TestCommandOutcome.Success, calls)] + ]), + actual + }); + + const failure = await expectDiagnostic(harness.runtime); + + expect(failure.index).toBe(0); + expect(failure.code).toBe(0); + expect(failure.reason).toContain('hero attribute hp differs'); + expect(calls).toEqual([0]); + expect(harness.finished()).toBe(true); + }); + + // 验证所有地图图层矩阵差异只在录像正常结束后报告 + it('reports a map matrix mismatch only after normal end', async () => { + const calls: number[] = []; + const actual = createSnapshot(); + actual.maps[0].layers[0].matrix[1] = 9; + const harness = createHarness({ + steps: [{ code: 0, params: [] }], + commands: new Map([ + [0, createCommand(TestCommandOutcome.Success, calls)] + ]), + actual + }); + + const failure = await expectDiagnostic(harness.runtime); + + expect(failure.index).toBe(0); + expect(failure.code).toBe(0); + expect(failure.reason).toContain( + 'map floor F1 layer 20 index 1 differs' + ); + expect(calls).toEqual([0]); + expect(harness.finished()).toBe(true); + }); +}); diff --git a/script/test-data-node.ts b/script/test-data-node.ts index 1b6154d..77c814d 100644 --- a/script/test-data-node.ts +++ b/script/test-data-node.ts @@ -40,8 +40,6 @@ interface IClosedLoopModule { } const require = createRequire(import.meta.url); -const closedLoopModule = - require('../packages-user/data-state/test/fixtures/closed-loop.ts') as IClosedLoopModule; interface IReplayVerifierSandbox { readonly ended: boolean; @@ -301,6 +299,8 @@ function createRuntime(fixture: IClosedLoopFixture): IReplayVerifierRuntime { } export async function runNodeReplayVerifier(): Promise { + const closedLoopModule = + require('../packages-user/data-state/test/fixtures/closed-loop.ts') as IClosedLoopModule; const fixture = closedLoopModule.createClosedLoopFixture(); await verifyReplay(createRuntime(fixture)); }