From 374274a4bb487cb5cf65700480c5acbd215b8b67 Mon Sep 17 00:00:00 2001 From: unanmed <1319491857@qq.com> Date: Wed, 16 Sep 2026 15:06:58 +0800 Subject: [PATCH] test(06-16): add CoreState save/load perf suite across item scales and compressions - saveables.perf.ts: 9 cases = 10/100/1000 items x No/Low/High compression - each sample round-trips all 5 registered saveables through saveState/loadState - synthetic item definitions via addTile -> itemStore.addItem -> equipment.add/addItem - warmup 3 + 20 samples, median/min/p95 via console.table, no assertions --- .../data-state/test/saveables.perf.ts | 223 ++++++++++++++++++ 1 file changed, 223 insertions(+) create mode 100644 packages-user/data-state/test/saveables.perf.ts diff --git a/packages-user/data-state/test/saveables.perf.ts b/packages-user/data-state/test/saveables.perf.ts new file mode 100644 index 0000000..cf7307e --- /dev/null +++ b/packages-user/data-state/test/saveables.perf.ts @@ -0,0 +1,223 @@ +// 性能测量:CoreState 整体存读档随件数与压缩档的耗时,只记录不断言 +import { afterAll, describe, it, vi } from 'vitest'; +import { + type IEnemyAttr, + type IHeroAttr, + type IItemRawData, + ItemCategory, + SaveCompression, + TileType +} from '@user/data-common'; +import { Enemy } from '@user/data-base'; +import { CoreState, createCoreState } from '../src/core'; + +vi.hoisted(() => { + vi.stubGlobal('main', { replayChecking: true }); + vi.stubGlobal('location', { origin: 'http://localhost' }); + Map.prototype.getOrInsert ??= function ( + this: Map, + key: K, + value: V + ): V { + const existing = this.get(key); + if (existing !== undefined) return existing; + this.set(key, value); + return value; + }; + 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; + }; +}); + +/** 预热次数,不计入采样 */ +const WARMUP_RUNS = 3; +/** 采样次数,取排序后的下中位数 */ +const SAMPLE_RUNS = 20; +/** 存读档覆盖的装备件数与消耗品条目数 */ +const ITEM_SCALES = [10, 100, 1000] as const; + +/** 一档压缩档位的枚举值与表格展示名 */ +interface CompressionCase { + /** 表格展示名 */ + readonly label: string; + /** 压缩档位枚举值 */ + readonly value: SaveCompression; +} + +/** 存读档覆盖的三档压缩,顺序固定 */ +const COMPRESSIONS: readonly CompressionCase[] = [ + { label: 'NoCompression', value: SaveCompression.NoCompression }, + { label: 'LowCompression', value: SaveCompression.LowCompression }, + { label: 'HighCompression', value: SaveCompression.HighCompression } +]; + +/** 单条性能记录,字段与控制台表格列一一对应 */ +interface PerfRecord { + /** 测量项名称 */ + case: string; + /** 规模档位 */ + scale: string; + /** 中位耗时,单位毫秒 */ + 'median ms': number; + /** 最小耗时,单位毫秒 */ + 'min ms': number; + /** 95 分位耗时,单位毫秒 */ + 'p95 ms': number; +} + +/** 本次运行累积的性能记录,由 afterAll 统一打印 */ +const records: PerfRecord[] = []; + +/** + * 测量一次用例:先预热若干次,再采样后取中位数、最小值与 p95 + * @param caseName 测量项名称 + * @param scale 规模档位 + * @param run 单次被测量的操作 + */ +function measureCase( + caseName: string, + scale: string, + run: () => void +): PerfRecord { + for (let i = 0; i < WARMUP_RUNS; i++) { + run(); + } + + const samples: number[] = []; + for (let i = 0; i < SAMPLE_RUNS; i++) { + const start = globalThis.performance.now(); + run(); + samples.push(globalThis.performance.now() - start); + } + + samples.sort((left, right) => left - right); + const record: PerfRecord = { + case: caseName, + scale, + 'median ms': Number(samples[SAMPLE_RUNS / 2].toFixed(3)), + 'min ms': Number(samples[0].toFixed(3)), + 'p95 ms': Number(samples[Math.ceil(SAMPLE_RUNS * 0.95) - 1].toFixed(3)) + }; + records.push(record); + return record; +} + +/** 构造一个带合成属性的怪物模板对象 */ +function createEnemy(hp: number = 20): Enemy { + return new Enemy('perf-enemy', 1, { + hp, + atk: 8, + def: 5, + money: 2, + exp: 3, + point: 1, + guard: new Set() + }); +} + +/** + * 构造一个完整的合成道具定义,装备与消耗品共用 + * @param num 道具编号 + * @param id 道具 id + * @param category 道具分类 + */ +function createItemRaw( + num: number, + id: string, + category: ItemCategory +): IItemRawData { + return { + num, + id, + category, + name: id, + text: id, + hideInToolbox: false, + effect: { useEvent: null, useEffect: () => {}, canUse: () => false }, + equip: { + slots: [0], + animate: 'sword', + value: new Map(), + percentage: new Map(), + loadEvent: null, + unloadEvent: null + } + }; +} + +/** + * 向图块与道具存储注册一个道具定义, + * 顺序不可颠倒:装备实例化依赖 itemStore 中已存在的定义 + * @param state 顶层数据端状态 + * @param item 道具定义 + */ +function registerItem(state: CoreState, item: IItemRawData): void { + state.tileStore.addTile({ + num: item.num, + id: item.id, + events: {}, + type: TileType.Item, + pass: { onlyEvents: false, inPass: 15, outPass: 15 }, + eventPass: true + }); + state.itemStore.addItem(item); +} + +/** + * 创建存读档夹具:先建立地图与怪物的合成基线, + * 再按件数写入等量的装备实例与消耗品条目 + * @param count 装备件数与消耗品条目数 + */ +function createSaveablesFixture(count: number): CoreState { + const state = createCoreState(); + const map = state.maps.createMap('F1', 2, 2); + map.addLayer(); + state.maps.compareWith( + new Map([['F1', new Map([[0, new Uint32Array(4)]])]]) + ); + state.enemyManager.compareWith(new Map([[1, createEnemy()]])); + for (let i = 0; i < count; i++) { + registerItem( + state, + createItemRaw(1000 + i, 'perf-equip-' + i, ItemCategory.Equipment) + ); + state.hero.items.equipment.add(1000 + i); + } + for (let i = 0; i < count; i++) { + registerItem( + state, + createItemRaw(2000 + i, 'perf-item-' + i, ItemCategory.Consumable) + ); + state.hero.items.addItem(2000 + i, 1); + } + return state; +} + +afterAll(() => { + console.table(records); +}); + +describe('存读档性能', () => { + for (const scale of ITEM_SCALES) { + for (const compression of COMPRESSIONS) { + // 覆盖指定件数的装备与消耗品在该压缩档下的一次完整存读档往返 + it(`measures ${scale} items with ${compression.label}`, () => { + const state = createSaveablesFixture(scale); + const label = `${scale}/${compression.label}`; + + measureCase('存读档', label, () => { + const snapshot = state.saveState(compression.value); + state.loadState(snapshot, compression.value); + }); + }); + } + } +});