diff --git a/packages-user/data-state/test/nodeReplay.test.ts b/packages-user/data-state/test/nodeReplay.test.ts index da4bbbb..ecb8474 100644 --- a/packages-user/data-state/test/nodeReplay.test.ts +++ b/packages-user/data-state/test/nodeReplay.test.ts @@ -5,7 +5,7 @@ import { IReplayVerifierRuntime, IReplayVerifierSnapshot, verifyReplay -} from '../../../script/test-data-node'; +} from './replayVerifier'; enum TestCommandOutcome { Success, diff --git a/packages-user/data-state/test/replayVerifier.ts b/packages-user/data-state/test/replayVerifier.ts new file mode 100644 index 0000000..92f233b --- /dev/null +++ b/packages-user/data-state/test/replayVerifier.ts @@ -0,0 +1,226 @@ +import { + IHeroAttr, + IReplayArray, + IReplayCommand, + IReplayStepHandler, + ReplayParamValue +} from '@user/data-common'; + +export interface IReplayVerifierLayerSnapshot { + readonly zIndex: number; + readonly matrix: Uint32Array; +} + +export interface IReplayVerifierFloorSnapshot { + readonly floorId: string; + readonly layers: readonly IReplayVerifierLayerSnapshot[]; +} + +export interface IReplayVerifierExpectedSnapshot { + readonly hero: IHeroAttr; + readonly maps: readonly IReplayVerifierFloorSnapshot[]; +} + +export interface IReplayVerifierSandbox { + readonly ended: boolean; +} + +export interface IReplayVerifierSnapshot { + readonly hero: IHeroAttr; + readonly maps: readonly IReplayVerifierFloorSnapshot[]; +} + +export interface IReplayVerifierRuntime { + readonly route: IReplayArray; + readonly expected: IReplayVerifierExpectedSnapshot; + readonly sandbox: IReplayVerifierSandbox; + getCommand(code: number): IReplayCommand | null; + step(): Promise; + finish(): Promise; + snapshot(): IReplayVerifierSnapshot; +} + +class ReplayVerifierError extends Error { + readonly index: number; + readonly code: number; + readonly params: readonly ReplayParamValue[]; + readonly reason: string; + + constructor( + index: number, + code: number, + params: readonly ReplayParamValue[], + reason: string + ) { + const copiedParams = Array.from(params); + super( + `Replay verifier divergence: index=${index}; code=${code}; params=${formatReplayParams(copiedParams)}; reason=${reason}` + ); + this.index = index; + this.code = code; + this.params = copiedParams; + this.reason = reason; + } +} + +function formatThrownReason(error: unknown): string { + if (error instanceof Error) return error.message; + return Object.prototype.toString.call(error); +} + +function createDiagnostic( + step: IReplayStepHandler | null, + index: number, + reason: string +): ReplayVerifierError { + return new ReplayVerifierError( + index, + step?.command ?? 0, + step?.params ?? [], + reason + ); +} + +function getLastStep(route: IReplayArray): IReplayStepHandler | null { + return route.length === 0 ? null : route.get(route.length - 1); +} + +function compareHero(expected: IHeroAttr, actual: IHeroAttr): string | null { + const expectedKeys = Object.keys(expected); + const actualKeys = Object.keys(actual); + if (expectedKeys.length !== actualKeys.length) { + return `hero attribute key count differs: expected ${expectedKeys.length}, actual ${actualKeys.length}`; + } + for (const key of expectedKeys) { + if (!Object.hasOwn(actual, key)) { + return `hero attribute ${key} is missing`; + } + const expectedValue = expected[key as keyof IHeroAttr]; + const actualValue = actual[key as keyof IHeroAttr]; + if (!Object.is(expectedValue, actualValue)) { + return `hero attribute ${key} differs: expected ${String(expectedValue)}, actual ${String(actualValue)}`; + } + } + return null; +} + +function compareMaps( + expected: readonly IReplayVerifierFloorSnapshot[], + actual: readonly IReplayVerifierFloorSnapshot[] +): string | null { + if (expected.length !== actual.length) { + return `map floor count differs: expected ${expected.length}, actual ${actual.length}`; + } + for (let floorIndex = 0; floorIndex < expected.length; floorIndex++) { + const expectedFloor = expected[floorIndex]; + const actualFloor = actual[floorIndex]; + if (expectedFloor.floorId !== actualFloor.floorId) { + return `map floor ${floorIndex} differs: expected ${expectedFloor.floorId}, actual ${actualFloor.floorId}`; + } + if (expectedFloor.layers.length !== actualFloor.layers.length) { + return `map floor ${expectedFloor.floorId} layer count differs: expected ${expectedFloor.layers.length}, actual ${actualFloor.layers.length}`; + } + for ( + let layerIndex = 0; + layerIndex < expectedFloor.layers.length; + layerIndex++ + ) { + const expectedLayer = expectedFloor.layers[layerIndex]; + const actualLayer = actualFloor.layers[layerIndex]; + if (expectedLayer.zIndex !== actualLayer.zIndex) { + return `map floor ${expectedFloor.floorId} layer ${layerIndex} z-index differs: expected ${expectedLayer.zIndex}, actual ${actualLayer.zIndex}`; + } + if (expectedLayer.matrix.length !== actualLayer.matrix.length) { + return `map floor ${expectedFloor.floorId} layer ${expectedLayer.zIndex} length differs: expected ${expectedLayer.matrix.length}, actual ${actualLayer.matrix.length}`; + } + for (let cell = 0; cell < expectedLayer.matrix.length; cell++) { + const expectedValue = expectedLayer.matrix[cell]; + const actualValue = actualLayer.matrix[cell]; + if (expectedValue !== actualValue) { + return `map floor ${expectedFloor.floorId} layer ${expectedLayer.zIndex} index ${cell} differs: expected ${expectedValue}, actual ${actualValue}`; + } + } + } + } + return null; +} + +export function formatReplayParams( + params: readonly ReplayParamValue[] +): string { + return `[${params.map(formatReplayParam).join(', ')}]`; +} + +function formatReplayParam(param: ReplayParamValue): string { + switch (typeof param) { + case 'bigint': + return `${param.toString()}n`; + case 'string': + return JSON.stringify(param); + case 'number': + if (Object.is(param, -0)) return '-0'; + if (Number.isNaN(param)) return 'NaN'; + if (param === Infinity) return 'Infinity'; + if (param === -Infinity) return '-Infinity'; + return param.toString(); + default: + return param ? 'true' : 'false'; + } +} + +export async function verifyReplay( + runtime: IReplayVerifierRuntime +): Promise { + for (let index = 0; index < runtime.route.length; index++) { + const step = runtime.route.get(index); + const command = runtime.getCommand(step.command); + if (!command) { + throw createDiagnostic( + step, + index, + `unknown replay command code ${step.command}` + ); + } + let success: boolean; + try { + success = await runtime.step(); + } catch (error) { + throw createDiagnostic( + step, + index, + `command threw: ${formatThrownReason(error)}` + ); + } + if (!success) { + throw createDiagnostic(step, index, 'command returned false'); + } + } + + const lastStep = getLastStep(runtime.route); + try { + await runtime.finish(); + } catch (error) { + throw createDiagnostic( + lastStep, + lastStep?.index ?? 0, + `replay did not reach normal end: ${formatThrownReason(error)}` + ); + } + if (!runtime.sandbox.ended) { + throw createDiagnostic( + lastStep, + lastStep?.index ?? 0, + 'replay did not reach normal end' + ); + } + + const actual = runtime.snapshot(); + const heroReason = compareHero(runtime.expected.hero, actual.hero); + if (heroReason) { + throw createDiagnostic(lastStep, lastStep?.index ?? 0, heroReason); + } + const mapReason = compareMaps(runtime.expected.maps, actual.maps); + if (mapReason) { + throw createDiagnostic(lastStep, lastStep?.index ?? 0, mapReason); + } +} diff --git a/script/test-data-node.ts b/script/test-data-node.ts index 77c814d..6ab09e7 100644 --- a/script/test-data-node.ts +++ b/script/test-data-node.ts @@ -1,30 +1,47 @@ -import { resolve } from 'node:path'; import { createRequire } from 'node:module'; +import { resolve } from 'node:path'; import { pathToFileURL } from 'node:url'; import { + IHeroAttr, IReplayArray, IReplayCommand, IReplaySandbox, - IReplayStepHandler, - IReplaySystem, - IHeroAttr, - ReplayParamValue + IReplaySystem } from '@user/data-common'; import { CoreState } from '../packages-user/data-state/src/core.ts'; -interface IClosedLoopLayerSnapshot { +interface IReplayVerifierLayerSnapshot { readonly zIndex: number; readonly matrix: Uint32Array; } -interface IClosedLoopFloorSnapshot { +interface IReplayVerifierFloorSnapshot { readonly floorId: string; - readonly layers: readonly IClosedLoopLayerSnapshot[]; + readonly layers: readonly IReplayVerifierLayerSnapshot[]; } -interface IClosedLoopExpectedSnapshot { +interface IReplayVerifierExpectedSnapshot { readonly hero: IHeroAttr; - readonly maps: readonly IClosedLoopFloorSnapshot[]; + readonly maps: readonly IReplayVerifierFloorSnapshot[]; +} + +interface IReplayVerifierSnapshot { + readonly hero: IHeroAttr; + readonly maps: readonly IReplayVerifierFloorSnapshot[]; +} + +interface IReplayVerifierRuntime { + readonly route: IReplayArray; + readonly expected: IReplayVerifierExpectedSnapshot; + readonly sandbox: { readonly ended: boolean }; + getCommand(code: number): IReplayCommand | null; + step(): Promise; + finish(): Promise; + snapshot(): IReplayVerifierSnapshot; +} + +interface IVerifierModule { + verifyReplay(runtime: IReplayVerifierRuntime): Promise; } interface IClosedLoopFixture { @@ -32,228 +49,28 @@ interface IClosedLoopFixture { readonly replay: IReplaySystem; readonly route: IReplayArray; readonly sandbox: IReplaySandbox; - readonly expected: IClosedLoopExpectedSnapshot; + readonly expected: IReplayVerifierExpectedSnapshot; } interface IClosedLoopModule { createClosedLoopFixture(): IClosedLoopFixture; } -const require = createRequire(import.meta.url); - -interface IReplayVerifierSandbox { - readonly ended: boolean; -} - interface IManualReplaySandbox extends IReplaySandbox { pausing: boolean; playing: boolean; } -export interface IReplayVerifierSnapshot { - readonly hero: IHeroAttr; - readonly maps: readonly IClosedLoopFloorSnapshot[]; -} +const require = createRequire(import.meta.url); +const verifierModule = + require('../packages-user/data-state/test/replayVerifier.ts') as IVerifierModule; -export interface IReplayVerifierRuntime { - readonly route: IReplayArray; - readonly expected: IClosedLoopExpectedSnapshot; - readonly sandbox: IReplayVerifierSandbox; - getCommand(code: number): IReplayCommand | null; - step(): Promise; - finish(): Promise; - snapshot(): IReplayVerifierSnapshot; -} - -class ReplayVerifierError extends Error { - readonly index: number; - readonly code: number; - readonly params: readonly ReplayParamValue[]; - readonly reason: string; - - constructor( - index: number, - code: number, - params: readonly ReplayParamValue[], - reason: string - ) { - const copiedParams = Array.from(params); - super( - `Replay verifier divergence: index=${index}; code=${code}; params=${formatReplayParams(copiedParams)}; reason=${reason}` - ); - this.index = index; - this.code = code; - this.params = copiedParams; - this.reason = reason; - } -} - -function formatThrownReason(error: unknown): string { - if (error instanceof Error) return error.message; - return Object.prototype.toString.call(error); -} - -function createDiagnostic( - step: IReplayStepHandler | null, - index: number, - reason: string -): ReplayVerifierError { - return new ReplayVerifierError( - index, - step?.command ?? 0, - step?.params ?? [], - reason - ); -} - -function getLastStep(route: IReplayArray): IReplayStepHandler | null { - return route.length === 0 ? null : route.get(route.length - 1); -} - -function compareHero(expected: IHeroAttr, actual: IHeroAttr): string | null { - const expectedKeys = Object.keys(expected); - const actualKeys = Object.keys(actual); - if (expectedKeys.length !== actualKeys.length) { - return `hero attribute key count differs: expected ${expectedKeys.length}, actual ${actualKeys.length}`; - } - for (const key of expectedKeys) { - if (!Object.hasOwn(actual, key)) { - return `hero attribute ${key} is missing`; - } - const expectedValue = expected[key as keyof IHeroAttr]; - const actualValue = actual[key as keyof IHeroAttr]; - if (!Object.is(expectedValue, actualValue)) { - return `hero attribute ${key} differs: expected ${String(expectedValue)}, actual ${String(actualValue)}`; - } - } - return null; -} - -function compareMaps( - expected: readonly IClosedLoopFloorSnapshot[], - actual: readonly IClosedLoopFloorSnapshot[] -): string | null { - if (expected.length !== actual.length) { - return `map floor count differs: expected ${expected.length}, actual ${actual.length}`; - } - for (let floorIndex = 0; floorIndex < expected.length; floorIndex++) { - const expectedFloor = expected[floorIndex]; - const actualFloor = actual[floorIndex]; - if (expectedFloor.floorId !== actualFloor.floorId) { - return `map floor ${floorIndex} differs: expected ${expectedFloor.floorId}, actual ${actualFloor.floorId}`; - } - if (expectedFloor.layers.length !== actualFloor.layers.length) { - return `map floor ${expectedFloor.floorId} layer count differs: expected ${expectedFloor.layers.length}, actual ${actualFloor.layers.length}`; - } - for ( - let layerIndex = 0; - layerIndex < expectedFloor.layers.length; - layerIndex++ - ) { - const expectedLayer = expectedFloor.layers[layerIndex]; - const actualLayer = actualFloor.layers[layerIndex]; - if (expectedLayer.zIndex !== actualLayer.zIndex) { - return `map floor ${expectedFloor.floorId} layer ${layerIndex} z-index differs: expected ${expectedLayer.zIndex}, actual ${actualLayer.zIndex}`; - } - if (expectedLayer.matrix.length !== actualLayer.matrix.length) { - return `map floor ${expectedFloor.floorId} layer ${expectedLayer.zIndex} length differs: expected ${expectedLayer.matrix.length}, actual ${actualLayer.matrix.length}`; - } - for (let cell = 0; cell < expectedLayer.matrix.length; cell++) { - const expectedValue = expectedLayer.matrix[cell]; - const actualValue = actualLayer.matrix[cell]; - if (expectedValue !== actualValue) { - return `map floor ${expectedFloor.floorId} layer ${expectedLayer.zIndex} index ${cell} differs: expected ${expectedValue}, actual ${actualValue}`; - } - } - } - } - return null; -} - -export function formatReplayParams( - params: readonly ReplayParamValue[] -): string { - return `[${params.map(formatReplayParam).join(', ')}]`; -} - -function formatReplayParam(param: ReplayParamValue): string { - switch (typeof param) { - case 'bigint': - return `${param.toString()}n`; - case 'string': - return JSON.stringify(param); - case 'number': - if (Object.is(param, -0)) return '-0'; - if (Number.isNaN(param)) return 'NaN'; - if (param === Infinity) return 'Infinity'; - if (param === -Infinity) return '-Infinity'; - return param.toString(); - default: - return param ? 'true' : 'false'; - } -} - -export async function verifyReplay( - runtime: IReplayVerifierRuntime -): Promise { - for (let index = 0; index < runtime.route.length; index++) { - const step = runtime.route.get(index); - const command = runtime.getCommand(step.command); - if (!command) { - throw createDiagnostic( - step, - index, - `unknown replay command code ${step.command}` - ); - } - let success: boolean; - try { - success = await runtime.step(); - } catch (error) { - throw createDiagnostic( - step, - index, - `command threw: ${formatThrownReason(error)}` - ); - } - if (!success) { - throw createDiagnostic(step, index, 'command returned false'); - } - } - - const lastStep = getLastStep(runtime.route); - try { - await runtime.finish(); - } catch (error) { - throw createDiagnostic( - lastStep, - lastStep?.index ?? 0, - `replay did not reach normal end: ${formatThrownReason(error)}` - ); - } - if (!runtime.sandbox.ended) { - throw createDiagnostic( - lastStep, - lastStep?.index ?? 0, - 'replay did not reach normal end' - ); - } - - const actual = runtime.snapshot(); - const heroReason = compareHero(runtime.expected.hero, actual.hero); - if (heroReason) { - throw createDiagnostic(lastStep, lastStep?.index ?? 0, heroReason); - } - const mapReason = compareMaps(runtime.expected.maps, actual.maps); - if (mapReason) { - throw createDiagnostic(lastStep, lastStep?.index ?? 0, mapReason); - } -} - -function captureMaps(state: CoreState): readonly IClosedLoopFloorSnapshot[] { - const floors: IClosedLoopFloorSnapshot[] = []; +function captureMaps( + state: CoreState +): readonly IReplayVerifierFloorSnapshot[] { + const floors: IReplayVerifierFloorSnapshot[] = []; for (const [floorId, map] of state.maps.iterateAllMaps()) { - const layers: IClosedLoopLayerSnapshot[] = []; + const layers: IReplayVerifierLayerSnapshot[] = []; for (const layer of map.layerList) { layers.push({ zIndex: layer.zIndex, @@ -302,7 +119,7 @@ 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)); + await verifierModule.verifyReplay(createRuntime(fixture)); } const isMainModule =