diff --git a/packages-user/data-base/src/map/mapLayer.ts b/packages-user/data-base/src/map/mapLayer.ts index 00c5dbf..be45f3e 100644 --- a/packages-user/data-base/src/map/mapLayer.ts +++ b/packages-user/data-base/src/map/mapLayer.ts @@ -213,10 +213,12 @@ export class MapLayer event(x: number, y: number): ILayerEventView | null { if (!this.inMap(x, y)) return null; const index = y * this.width + x; - return this.pointEvents.getOrInsertComputed( - index, - () => new LayerEventView() - ); + let eventView = this.pointEvents.get(index); + if (!eventView) { + eventView = new LayerEventView(); + this.pointEvents.set(index, eventView); + } + return eventView; } getPointEvent(x: number, y: number): ReadonlyMap | null { diff --git a/packages-user/data-common/src/replay/sandbox.ts b/packages-user/data-common/src/replay/sandbox.ts index add7e46..376eca1 100644 --- a/packages-user/data-common/src/replay/sandbox.ts +++ b/packages-user/data-common/src/replay/sandbox.ts @@ -116,7 +116,7 @@ export class ReplaySandbox } async step(): Promise { - if (!this.pausing || !this.playing || this.ended) return false; + if (!this.playing || this.ended) return false; if (this.reader.expired) { logger.warn(156); return false; diff --git a/packages-user/data-state/src/core.ts b/packages-user/data-state/src/core.ts index 05b4cca..51e7b58 100644 --- a/packages-user/data-state/src/core.ts +++ b/packages-user/data-state/src/core.ts @@ -46,7 +46,6 @@ import { } from '@user/data-system'; import { CommonAuraConverter, - EnemyLegacyBridge, GuardAuraConverter, MainDamageCalculator, MainEnemyFinalEffect, diff --git a/packages-user/data-state/test/fixtures/closed-loop.ts b/packages-user/data-state/test/fixtures/closed-loop.ts new file mode 100644 index 0000000..93dbed3 --- /dev/null +++ b/packages-user/data-state/test/fixtures/closed-loop.ts @@ -0,0 +1,113 @@ +import { AnonTokyoInterpreter } from 'anon-tokyo'; +import { + EventTrigger, + FaceDirection, + IReplaySandbox, + TileType +} from '@user/data-common'; +import { IGameMap, IMapLayer } from '@user/data-base'; +import { IReadonlyBlockEvent } from '@user/data-system'; +import { ReplaySystem } from '../../../data-common/src/replay/system'; +import { createCoreState, CoreState } from '../../src/core'; + +export interface IClosedLoopFixture { + readonly state: CoreState; + readonly map: IGameMap; + readonly eventLayer: IMapLayer; + readonly replay: ReplaySystem; + readonly sandbox: IReplaySandbox; + readonly eventCompleted: () => boolean; +} + +const MOVE_RIGHT_COMMAND = 1; + +export function createClosedLoopFixture(): IClosedLoopFixture { + const state = createCoreState(); + state.tileStore.addTile({ + num: 1, + id: 'floor', + events: { 10: 'mutate-map' }, + type: TileType.Terrain, + pass: { onlyEvents: false, outPass: 0b1111, inPass: 0b1111 }, + eventPass: true + }); + state.tileStore.addTile({ + num: 2, + id: 'changed-floor', + events: {}, + type: TileType.Terrain, + pass: { onlyEvents: false, outPass: 0b1111, inPass: 0b1111 }, + eventPass: true + }); + + const map = state.maps.fromRaw({ + floorId: 'F1', + width: 3, + map: { 20: [1, 1, 1] }, + layerAlias: { 20: 'event' }, + events: { 20: {} } + }); + if (!map || !map.eventLayer) { + throw new Error('closed-loop fixture map was not created'); + } + const eventLayer = map.eventLayer; + let completed = false; + const event: IReadonlyBlockEvent = { + interpreter: new AnonTokyoInterpreter({ + builtInFunctions: [], + globalFunctions: [] + }), + trigger: EventTrigger.OnEnter, + rawEvent: [], + compiled: null, + compile: () => null, + execute: async (_param, env) => { + await Promise.resolve(); + if (!env.layer || !env.triggerLocator) { + throw new Error('closed-loop event lost its map source'); + } + env.layer.setBlock(2, env.triggerLocator.x, env.triggerLocator.y); + completed = true; + } + }; + state.eventStore.addEvent('mutate-map', event); + + state.hero.location.setFloor('F1'); + 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 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); + completed = false; + }; + const sandbox = replay.createReplaySandbox({ + route: replay.route, + reseter: { reset } + }); + + return { + state, + map, + eventLayer, + replay, + sandbox, + eventCompleted: () => completed + }; +} diff --git a/packages-user/data-state/test/nodeTracer.test.ts b/packages-user/data-state/test/nodeTracer.test.ts new file mode 100644 index 0000000..9f5934f --- /dev/null +++ b/packages-user/data-state/test/nodeTracer.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; +import { createCoreState } from '../src/core'; +import { createClosedLoopFixture } from './fixtures/closed-loop'; + +describe('Node replay tracer', () => { + // 验证 Node 宿主无需浏览器全局即可创建相互独立的内存状态 + it('creates independent Node-safe CoreState instances', () => { + const first = createCoreState(); + const second = createCoreState(); + + expect(first).not.toBe(second); + expect(first.saveSystem).not.toBe(second.saveSystem); + expect(first.saveSystem.constructor.name).toBe('MemorySaveSystem'); + expect(second.saveSystem.constructor.name).toBe('MemorySaveSystem'); + }); + + // 验证 replay 等待真实移动与事件完成后才结束并改变事件层矩阵 + it('replays movement, awaits event mutation, and ends normally', async () => { + const fixture = createClosedLoopFixture(); + + expect(fixture.eventLayer.getBlock(0, 0)).toBe(1); + expect(fixture.eventLayer.getBlock(1, 0)).toBe(1); + + fixture.sandbox.play(); + await waitForEnded(fixture.sandbox); + + expect(fixture.sandbox.ended).toBe(true); + expect(fixture.sandbox.getReplayed()).toBe(1); + expect(fixture.state.hero.location.x).toBe(1); + expect(fixture.state.hero.location.y).toBe(0); + expect(fixture.eventCompleted()).toBe(true); + expect(fixture.eventLayer.getBlock(1, 0)).toBe(2); + }); +}); + +async function waitForEnded(sandbox: { + readonly ended: boolean; +}): Promise { + for (let index = 0; index < 100 && !sandbox.ended; index++) { + await new Promise(resolve => setTimeout(resolve, 0)); + } + if (!sandbox.ended) { + throw new Error('replay did not reach its end state'); + } +}