diff --git a/.planning/phases/02-pathfinding/02-02-SUMMARY.md b/.planning/phases/02-pathfinding/02-02-SUMMARY.md index c444c16..29ab83b 100644 --- a/.planning/phases/02-pathfinding/02-02-SUMMARY.md +++ b/.planning/phases/02-pathfinding/02-02-SUMMARY.md @@ -238,3 +238,41 @@ None - no external service configuration required. - 验证命令复跑(合规补丁后):mover.test.ts 4/4 + path 目录 18/18 全绿;eslint 目标目录 0 problems;`check:type` 过滤 `src[\\/]path[\\/]` 无输出;`check:circular` 无 path 相关路径(既有基线循环不变) - requirements.ready-ids:0/2 ready(PATH-01/PATH-02 与 02-03 共享,shared-ID 门生效),frontmatter `requirements-completed: []` - broken-windows 台账:02-01 的 skipped-test #4 标记 fixed;新增 #5(interrupt 占位 stub, open) + +## Review Fixes (user audit round 1) + +> 用户对 Wave 2 产出的代码审查修复轮(9 条指令全部落实);根因已由用户在 `9a60442` 自行修复(`useMover(mover: IObjectMover | null)`),本轮对齐实现侧。 + +### 性能测试结果(完整 find 流水线:建图 + 最优路径发现) + +```text +[pathfinding-perf] map=100x100 steps=198 elapsed=608.55ms +``` + +- 地图:100 x 100(10000 节点),固定种子生成约 20% 墙体的确定性障碍图案,起点 (0,0) 至终点 (99,99) +- 最优步数:198(恰为 99 + 99 的曼哈顿距离,绕障未增加步数) +- 总耗时:608.55 ms(`performance.now()` 计时,宽松 sanity 上限 5000 ms),`pnpm exec vitest run` 单测 619ms 完成全流程 + +### 指令落实明细 + +| # | 指令 | 落实情况 | 验证 | +| - | ---- | ---- | ---- | +| 1 | 禁止 `'mover' in xxx` 运行时形状检查 | 删除 `IMovableWithMover` 接口与 `hasMover` 结构化守卫,`PathfindingSystem` 直接持有 `IObjectMover \| null` 类型绑定,位置经 `mover.tile.x/y` 读取;path/*.ts 复扫 0 残留 | grep `'mover' in` 无命中;23/23 测试绿 | +| 2 | 类与接口本身不带 doc 注释 | 移除 `PathfindingGraphBuilder`/`PathfindingFinder`/`PathfindingSystem` 类注释与 `IPathfindingGraphBuilder` 接口注释(类型迁入 types.ts 时同样不写接口注释);测试侧移除纯复述的 `TestTileDefinition`/`TestTile`/`SystemFixture` 注释 | 逐文件核查;例外:测试 `FixturePredicate` 保留(「复刻 DefaultHeroMoveTopImpl 掩码语义」为来源说明,dev.md 特殊情况例外) | +| 3 | 实现侧不重复接口已有的注释 | 类实现侧与接口逐字重复的方法 jsdoc 全部移除(useMapState/useMapLayer/useCostFunction/usePassPredicate/useDirGroup/useFallbackPolicy/useMover/moveTo/teleportTo/interrupt/build);仅保留内容确不相同者:`find`(告警+空数组契约、不可达语义)与 `getPath`(未绑定告警语义) | 类型门 0 诊断 | +| 4 | 导出类型归位 types.ts | `IPathGraphEdge`/`IPathGraphNode`/`IPathGraph`/`IPathfindingGraphBuilder` 迁入 path/types.ts,graph.ts/finder.ts 改由 `./types` 导入;未改动用户既有接口语义 | `pnpm check:type` 过滤 `src[\\/]path[\\/]` 无输出 | +| 5 | 私有方法先于调用者 | `resolveFloorId` 移至 `build` 前;`getNodeCost`→`search`→`find` 链式前置;`startMove` 移至 `moveTo`/`teleportTo` 前 | 代码走查 | +| 6 | 短文件不用 region | 移除 graph.ts/finder.ts/system.ts 全部 `#region`/`#endregion`(移动器识别/寻路系统等 5 处) | grep 无命中 | +| 7 | 注释面向 API 使用者而非维护者 | `search` jsdoc 去除「Dijkstra」算法叙述;`getNodeCost` 去除「非负权不变式」内部不变量叙述;`interrupt` 去除「03 计划接线」计划性叙述(其余与接口重复故整条移除);保留的 jsdoc 均为可观察行为契约 | 代码走查 | +| 8 | 对齐新 types.ts(`useMover`) | `useMover` 参数改收 `IObjectMover \| null`,字段 `movable`→`mover`,测试改传 `tile.mover`,测试描述同步(movable→mover);path/*.ts 无 `movable` 残留标识符 | grep `movable` 无命中;check:type 0 诊断 | +| 9 | 性能测试 | 新建 `path/performance.test.ts`:100x100 固定种子障碍图,计时 `system.getPath()`(绑定系统上的完整建图+搜索流水线),结构化输出 map/steps/elapsed,断言找到路径且耗时低于 5000ms sanity 上限,常驻通过不 skip | 见上方性能结果 | + +### 验证汇总(修复轮全部复核) + +- `pnpm exec vitest run "packages-user/data-system/src/path"` — 4 文件 23 passed(含新增性能测试) +- `pnpm exec vitest run "packages-user/data-common/src/common/mover.test.ts"` — 4/4 绿 +- `pnpm check:type` 过滤 `src[\\/]path[\\/]|common[\\/]mover` — 0 诊断(其余 legacy 既有诊断不在本计划范围) +- `pnpm check:circular` — 无涉及 path 的循环(`common/mover.ts` 所在循环节点全部为本轮未触碰的既有基线边) +- `pnpm exec eslint` 目标目录(path/ + data-common mover 两文件)— 0 problems + +**提交:** `d9ee80f`(refactor,指令 1–8)、本提交(test,指令 9 + 本记录) diff --git a/packages-user/data-system/src/path/performance.test.ts b/packages-user/data-system/src/path/performance.test.ts new file mode 100644 index 0000000..c5d9f9e --- /dev/null +++ b/packages-user/data-system/src/path/performance.test.ts @@ -0,0 +1,294 @@ +// 测试寻路性能:从有向图构建到最优路径发现的完整 find 流水线总耗时 +import { beforeAll, describe, expect, it, vi } from 'vitest'; +import { type ITileLocator } from '@motajs/common'; +import { FaceDirection } from '@user/data-common'; +import { + type IDataCommon, + type IFaceHandler, + type IObjectMovable, + type IObjectMover, + type ITileStore +} from '@user/data-common'; +import { + type IGameMap, + type IPassCheckHandler, + type IPassPredicate +} from '@user/data-base'; +import { type PathfindingSystem } from './system'; + +vi.hoisted(() => { + vi.stubGlobal('main', { replayChecking: true }); + vi.stubGlobal('location', { origin: 'http://localhost' }); + Map.prototype.getOrInsertComputed ??= function ( + this: Map, + key: K, + callback: (key: K) => V + ): V { + const existing = this.get(key); + if (existing !== undefined) return existing; + const value = callback(key); + this.set(key, value); + return value; + }; +}); + +interface TestModules { + PathfindingSystem: typeof import('./system').PathfindingSystem; + MapState: typeof import('@user/data-base').MapState; + TileStore: typeof import('@user/data-common').TileStore; + ObjectMover: typeof import('@user/data-common').ObjectMover; + FaceManager: typeof import('@user/data-common').FaceManager; + Dir8FaceHandler: typeof import('@user/data-common').Dir8FaceHandler; + RoleFaceBinder: typeof import('@user/data-common').RoleFaceBinder; +} + +let modules: TestModules; + +beforeAll(async () => { + vi.stubGlobal('main', { replayChecking: true }); + vi.stubGlobal('location', { origin: 'http://localhost' }); + const systemModule = await import('./system'); + const baseModule = await import('@user/data-base'); + const commonModule = await import('@user/data-common'); + modules = { + PathfindingSystem: systemModule.PathfindingSystem, + MapState: baseModule.MapState, + TileStore: commonModule.TileStore, + ObjectMover: commonModule.ObjectMover, + FaceManager: commonModule.FaceManager, + Dir8FaceHandler: commonModule.Dir8FaceHandler, + RoleFaceBinder: commonModule.RoleFaceBinder + }; +}); + +/** 性能地图边长,共 100 x 100 = 10000 个节点 */ +const MAP_SIZE = 100; + +/** 性能测试的宽松耗时上限,单位 ms,仅作 sanity 把关 */ +const ELAPSED_LIMIT_MS = 5000; + +interface TestTile extends IObjectMovable { + x: number; + y: number; + face: FaceDirection; + mover: IObjectMover; +} + +/** + * 复刻 DefaultHeroMoveTopImpl 掩码语义的测试谓词: + * 事件层恒参与判定,四角朝向直接放行 + */ +class FixturePredicate implements IPassPredicate { + /** 绑定的楼层地图对象 */ + private readonly map: IGameMap; + /** 朝向管理对象,用于求相反方向 */ + private readonly face: IFaceHandler; + + constructor(map: IGameMap, face: IFaceHandler) { + this.map = map; + this.face = face; + } + + /** + * 将朝向转换为对应的通行性位掩码 + * @param dir 朝向 + */ + private passBit(dir: FaceDirection): number { + switch (dir) { + case FaceDirection.Up: + return 0b0001; + case FaceDirection.Right: + return 0b0010; + case FaceDirection.Down: + return 0b0100; + case FaceDirection.Left: + return 0b1000; + default: + return 0; + } + } + + canPass(handler: IPassCheckHandler): boolean { + const event = this.map.eventLayer; + if (!event) return false; + const { currLoc, nextLoc, direction } = handler; + + // 四角朝向直接判定为可通行,与 moverImpl 语义一致 + if ( + direction === FaceDirection.LeftDown || + direction === FaceDirection.LeftUp || + direction === FaceDirection.RightDown || + direction === FaceDirection.RightUp + ) { + return true; + } + + const opposite = this.face.opposite(direction); + const leaveMask = this.passBit(direction); + const enterMask = this.passBit(opposite); + + let canLeave = true; + let canEnter = true; + const curr = event.getLocationData(currLoc.x, currLoc.y); + const next = event.getLocationData(nextLoc.x, nextLoc.y); + const currRaw = curr?.static.raw(); + const nextRaw = next?.static.raw(); + if (currRaw) { + canLeave = !!(leaveMask & currRaw.pass.outPass); + } + if (nextRaw) { + canEnter = !!(enterMask & nextRaw.pass.inPass); + } + return canLeave && canEnter; + } + + shouldHit(): boolean { + return false; + } +} + +/** + * 以固定种子生成含约两成墙体的确定性地图数据, + * 起点与终点强制为开阔图块 + * @param size 地图边长 + */ +function generateRows(size: number): number[] { + let seed = 20260909; + const next = () => { + seed = (seed * 1664525 + 1013904223) % 4294967296; + return seed / 4294967296; + }; + const rows: number[] = []; + for (let i = 0; i < size * size; i++) { + rows.push(next() < 0.2 ? 6 : 1); + } + rows[0] = 1; + rows[size * size - 1] = 1; + return rows; +} + +/** + * 创建绑定 100 x 100 地图与移动对象的寻路系统夹具 + * @param rows 每行图块数字,行长为宽度乘高度 + * @param width 地图宽度 + */ +function createPerformanceSystem(rows: number[], width: number) { + const tileStore: ITileStore = new modules.TileStore() as never; + const tileDefs = [ + { num: 1, id: 'open', outPass: 15, inPass: 15 }, + { num: 6, id: 'wall', outPass: 0, inPass: 0 } + ]; + for (const tile of tileDefs) { + tileStore.addTile({ + num: tile.num, + id: tile.id, + events: {}, + type: 0, + pass: { + onlyEvents: false, + outPass: tile.outPass, + inPass: tile.inPass + }, + eventPass: true + }); + } + const faceManager = new modules.FaceManager(); + faceManager.register(1, new modules.Dir8FaceHandler()); + const commonState: IDataCommon = { + tileStore, + itemStore: {}, + mapStore: {}, + eventStore: {}, + roleFace: new modules.RoleFaceBinder(), + faceManager, + saveSystem: {} + } as never; + const maps = new modules.MapState(tileStore, commonState); + const map = maps.fromRaw({ + floorId: 'PERF', + width, + map: { 0: rows }, + layerAlias: { 0: 'event' }, + events: { 0: {} } + })!; + const layer = map.getLayerByAlias('event')!; + const predicate = new FixturePredicate(map, new modules.Dir8FaceHandler()); + + class PerfMover + extends modules.ObjectMover + implements IObjectMover + { + readonly tile: TestTile; + + constructor(tile: TestTile) { + super(new modules.Dir8FaceHandler(), FaceDirection.Down); + this.tile = tile; + } + + protected override async onMoveStart(): Promise {} + + protected override async onMoveEnd(): Promise {} + + protected override async onStepStart(): Promise { + return 0; + } + + protected override async onStepEnd(): Promise { + return { x: this.tile.x, y: this.tile.y }; + } + + protected override async onStepSettled(): Promise {} + } + + class PerfTile implements TestTile { + x: number = 0; + y: number = 0; + face: FaceDirection = FaceDirection.Down; + mover: IObjectMover; + + constructor() { + this.mover = new PerfMover(this); + } + + setPos(x: number, y: number): void { + this.x = x; + this.y = y; + } + + getCurrentFaceDirection(): FaceDirection { + return this.face; + } + } + + const tile = new PerfTile(); + const system: PathfindingSystem = new modules.PathfindingSystem( + commonState as never + ); + system.useMover(tile.mover); + system.finder.useMapState(maps); + system.finder.useMapLayer(layer); + system.finder.usePassPredicate(predicate); + return system; +} + +describe('pathfinding performance', () => { + // 验证 100 x 100 障碍地图上从建图到最优路径发现的完整寻路耗时处于可用量级 + it('completes the full find pipeline on a 100x100 map within the sanity bound', () => { + const rows = generateRows(MAP_SIZE); + const system = createPerformanceSystem(rows, MAP_SIZE); + + const start = performance.now(); + const steps = system.getPath({ x: MAP_SIZE - 1, y: MAP_SIZE - 1 }); + const elapsed = performance.now() - start; + + // 审查要求输出结构化性能数据供汇报使用 + // eslint-disable-next-line no-console + console.log( + `[pathfinding-perf] map=${MAP_SIZE}x${MAP_SIZE} ` + + `steps=${steps.length} elapsed=${elapsed.toFixed(2)}ms` + ); + + expect(steps.length).toBeGreaterThan(0); + expect(elapsed).toBeLessThan(ELAPSED_LIMIT_MS); + }); +});