diff --git a/package.json b/package.json index ba8c7d8..4a2476e 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "dev": "tsx script/dev.ts", "test": "vitest", "test:ci": "vitest run", + "test:data-node": "tsx script/test-data-node.ts", "preview": "vite preview", "declare": "tsx script/declare.ts", "lines": "tsx script/lines.ts packages packages-user", diff --git a/packages-user/data-state/test/fixtures/closed-loop.ts b/packages-user/data-state/test/fixtures/closed-loop.ts index 93dbed3..35aedfe 100644 --- a/packages-user/data-state/test/fixtures/closed-loop.ts +++ b/packages-user/data-state/test/fixtures/closed-loop.ts @@ -3,24 +3,60 @@ import { EventTrigger, FaceDirection, IReplaySandbox, + IReplaySystem, + IReplayArray, + IEnemyAttr, + IHeroAttr, + SaveCompression, TileType } from '@user/data-common'; -import { IGameMap, IMapLayer } from '@user/data-base'; +import { + IEnemyManagerSaveState, + IFlagSystemSave, + IGameMap, + IHeroStateSave, + IMapLayer, + IMapStoreSave +} from '@user/data-base'; import { IReadonlyBlockEvent } from '@user/data-system'; -import { ReplaySystem } from '../../../data-common/src/replay/system'; -import { createCoreState, CoreState } from '../../src/core'; +import { CoreState, createCoreState } from '../../src/core.ts'; +import { ReplayCommandCode } from '../../src/replay/types.ts'; + +export interface IClosedLoopLayerSnapshot { + readonly zIndex: number; + readonly matrix: Uint32Array; +} + +export interface IClosedLoopFloorSnapshot { + readonly floorId: string; + readonly layers: readonly IClosedLoopLayerSnapshot[]; +} + +export interface IClosedLoopExpectedSnapshot { + readonly hero: IHeroAttr; + readonly maps: readonly IClosedLoopFloorSnapshot[]; +} + +export interface IClosedLoopInitialState { + readonly hero: IHeroStateSave; + readonly flags: IFlagSystemSave; + readonly maps: IMapStoreSave; + readonly enemy: IEnemyManagerSaveState; +} export interface IClosedLoopFixture { readonly state: CoreState; readonly map: IGameMap; readonly eventLayer: IMapLayer; - readonly replay: ReplaySystem; + readonly replay: IReplaySystem; + readonly route: IReplayArray; readonly sandbox: IReplaySandbox; + readonly initialState: IClosedLoopInitialState; + readonly expected: IClosedLoopExpectedSnapshot; + readonly reset: () => void; readonly eventCompleted: () => boolean; } -const MOVE_RIGHT_COMMAND = 1; - export function createClosedLoopFixture(): IClosedLoopFixture { const state = createCoreState(); state.tileStore.addTile({ @@ -43,14 +79,63 @@ export function createClosedLoopFixture(): IClosedLoopFixture { const map = state.maps.fromRaw({ floorId: 'F1', width: 3, - map: { 20: [1, 1, 1] }, - layerAlias: { 20: 'event' }, - events: { 20: {} } + map: { + 0: [7, 7, 7], + 10: [8, 8, 8], + 20: [1, 1, 1], + 30: [9, 9, 9], + 40: [10, 10, 10] + }, + layerAlias: { + 0: 'bg', + 10: 'bg2', + 20: 'event', + 30: 'fg', + 40: 'fg2' + }, + events: { + 0: {}, + 10: {}, + 20: {}, + 30: {}, + 40: {} + } }); if (!map || !map.eventLayer) { throw new Error('closed-loop fixture map was not created'); } const eventLayer = map.eventLayer; + const secondMap = state.maps.fromRaw({ + floorId: 'F2', + width: 2, + map: { + 0: [11, 11, 11, 11], + 10: [12, 12, 12, 12], + 20: [13, 13, 13, 13], + 30: [14, 14, 14, 14], + 40: [15, 15, 15, 15] + }, + layerAlias: { + 0: 'bg', + 10: 'bg2', + 20: 'event', + 30: 'fg', + 40: 'fg2' + }, + events: { + 0: {}, + 10: {}, + 20: {}, + 30: {}, + 40: {} + } + }); + if (!secondMap || !secondMap.eventLayer) { + throw new Error('closed-loop fixture second map was not created'); + } + state.maps.setMapActiveStatus('F1', true); + state.maps.setMapActiveStatus('F2', true); + let completed = false; const event: IReadonlyBlockEvent = { interpreter: new AnonTokyoInterpreter({ @@ -76,25 +161,82 @@ export function createClosedLoopFixture(): IClosedLoopFixture { state.hero.location.setPos(0, 0); state.hero.location.mover.setFaceDir(FaceDirection.Right); - const replay = new ReplaySystem(); - replay.registerCommand(MOVE_RIGHT_COMMAND, { - execute: async step => { - if (step.params.length !== 0) return false; - const mover = state.hero.location.mover; - mover.step(FaceDirection.Right); - const controller = mover.start(); - if (!controller) return false; - await controller.onEnd; - return true; - } - }); - replay.record(MOVE_RIGHT_COMMAND); + const replay = state.replaySystem; + replay.record(ReplayCommandCode.Right); + const route = replay.route; + + const initialState: IClosedLoopInitialState = { + hero: state.hero.saveState(SaveCompression.NoCompression), + flags: state.flags.saveState(SaveCompression.NoCompression), + maps: state.maps.saveState(SaveCompression.NoCompression), + enemy: state.enemyManager.saveState(SaveCompression.NoCompression) + }; + + const expected: IClosedLoopExpectedSnapshot = { + hero: { + name: '', + hp: 1, + hpmax: 0, + atk: 0, + def: 0, + mdef: 0, + mana: 0, + manamax: 0, + money: 0, + exp: 0 + }, + maps: [ + { + floorId: 'F1', + layers: [ + { zIndex: 0, matrix: new Uint32Array([7, 7, 7]) }, + { zIndex: 10, matrix: new Uint32Array([8, 8, 8]) }, + { zIndex: 20, matrix: new Uint32Array([1, 2, 1]) }, + { zIndex: 30, matrix: new Uint32Array([9, 9, 9]) }, + { zIndex: 40, matrix: new Uint32Array([10, 10, 10]) } + ] + }, + { + floorId: 'F2', + layers: [ + { + zIndex: 0, + matrix: new Uint32Array([11, 11, 11, 11]) + }, + { + zIndex: 10, + matrix: new Uint32Array([12, 12, 12, 12]) + }, + { + zIndex: 20, + matrix: new Uint32Array([13, 13, 13, 13]) + }, + { + zIndex: 30, + matrix: new Uint32Array([14, 14, 14, 14]) + }, + { + zIndex: 40, + matrix: new Uint32Array([15, 15, 15, 15]) + } + ] + } + ] + }; const reset = () => { - eventLayer.setMapRef(new Uint32Array([1, 1, 1])); state.hero.location.setFloor('F1'); state.hero.location.setPos(0, 0); state.hero.location.mover.setFaceDir(FaceDirection.Right); + state.flags.loadState( + initialState.flags, + SaveCompression.NoCompression + ); + state.maps.loadState(initialState.maps, SaveCompression.NoCompression); + state.enemyManager.loadState( + initialState.enemy, + SaveCompression.NoCompression + ); completed = false; }; const sandbox = replay.createReplaySandbox({ @@ -107,7 +249,11 @@ export function createClosedLoopFixture(): IClosedLoopFixture { map, eventLayer, replay, + route, sandbox, + initialState, + expected, + reset, eventCompleted: () => completed }; } diff --git a/script/test-data-node.ts b/script/test-data-node.ts new file mode 100644 index 0000000..1b6154d --- /dev/null +++ b/script/test-data-node.ts @@ -0,0 +1,323 @@ +import { resolve } from 'node:path'; +import { createRequire } from 'node:module'; +import { pathToFileURL } from 'node:url'; +import { + IReplayArray, + IReplayCommand, + IReplaySandbox, + IReplayStepHandler, + IReplaySystem, + IHeroAttr, + ReplayParamValue +} from '@user/data-common'; +import { CoreState } from '../packages-user/data-state/src/core.ts'; + +interface IClosedLoopLayerSnapshot { + readonly zIndex: number; + readonly matrix: Uint32Array; +} + +interface IClosedLoopFloorSnapshot { + readonly floorId: string; + readonly layers: readonly IClosedLoopLayerSnapshot[]; +} + +interface IClosedLoopExpectedSnapshot { + readonly hero: IHeroAttr; + readonly maps: readonly IClosedLoopFloorSnapshot[]; +} + +interface IClosedLoopFixture { + readonly state: CoreState; + readonly replay: IReplaySystem; + readonly route: IReplayArray; + readonly sandbox: IReplaySandbox; + readonly expected: IClosedLoopExpectedSnapshot; +} + +interface IClosedLoopModule { + createClosedLoopFixture(): IClosedLoopFixture; +} + +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; +} + +interface IManualReplaySandbox extends IReplaySandbox { + pausing: boolean; + playing: boolean; +} + +export interface IReplayVerifierSnapshot { + readonly hero: IHeroAttr; + readonly maps: readonly IClosedLoopFloorSnapshot[]; +} + +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[] = []; + for (const [floorId, map] of state.maps.iterateAllMaps()) { + const layers: IClosedLoopLayerSnapshot[] = []; + for (const layer of map.layerList) { + layers.push({ + zIndex: layer.zIndex, + matrix: new Uint32Array(layer.getMapData()) + }); + } + layers.sort((first, second) => first.zIndex - second.zIndex); + floors.push({ floorId, layers }); + } + return floors; +} + +async function waitForEnded(sandbox: IReplaySandbox): Promise { + for (let index = 0; index < 1000 && !sandbox.ended; index++) { + await new Promise(resolvePromise => + setTimeout(resolvePromise, 0) + ); + } + if (!sandbox.ended) throw new Error('replay did not reach normal end'); +} + +function createRuntime(fixture: IClosedLoopFixture): IReplayVerifierRuntime { + const sandbox = fixture.sandbox as IManualReplaySandbox; + sandbox.pausing = false; + sandbox.playing = true; + return { + route: fixture.route, + expected: fixture.expected, + sandbox, + getCommand: (code: number) => fixture.replay.getCommand(code), + step: () => sandbox.step(), + finish: async () => { + sandbox.playing = false; + sandbox.pausing = true; + sandbox.play(); + await waitForEnded(sandbox); + }, + snapshot: () => ({ + hero: fixture.state.hero.attribute.toStructured(), + maps: captureMaps(fixture.state) + }) + }; +} + +export async function runNodeReplayVerifier(): Promise { + const fixture = closedLoopModule.createClosedLoopFixture(); + await verifyReplay(createRuntime(fixture)); +} + +const isMainModule = + process.argv[1] !== undefined && + import.meta.url === pathToFileURL(resolve(process.argv[1])).href; + +if (isMainModule) { + runNodeReplayVerifier() + .then(() => { + process.stdout.write('Node replay verifier passed\n'); + }) + .catch(error => { + const message = + error instanceof Error ? error.message : String(error); + process.stderr.write(`${message}\n`); + process.exitCode = 1; + }); +}