Files
template/packages-user/data-state/__test__/saveables.perf.ts
T
AncTe ff9fd77880 test(08-09): align data-state integration and perf tests to shipped shapes
- dataClosure: local CoreState helper, Record equip.value, ReplayCommandResult/Active commands, natural sandbox step

- enemyCombination: local CoreState helper

- perf: CoreState loadStarter assembly, Record equip.value, addLayer('event')
2026-10-07 19:13:15 +08:00

249 lines
7.5 KiB
TypeScript

// 性能测量:CoreState 整体存读档随件数与压缩档的耗时,只记录不断言
import { afterAll, describe, it, vi } from 'vitest';
import { WebLoadStarter } from '@motajs/loader';
import {
type IEnemyAttr,
type IHeroAttr,
type IItemRawData,
ItemCategory,
SaveCompression,
TileType
} from '@user/data-common';
import { Enemy } from '@user/data-base';
import { CoreState } from '../src/core';
/** 按 08-08 约定的 CoreState 装配创建一个独立状态实例 */
function createCoreState(): CoreState {
return new CoreState({
loadStarter: new WebLoadStarter(),
coreURL: 'placeholder'
});
}
vi.hoisted(() => {
vi.stubGlobal('main', { replayChecking: true });
vi.stubGlobal('location', { origin: 'http://localhost' });
Map.prototype.getOrInsert ??= function <K, V>(
this: Map<K, V>,
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 <K, V>(
this: Map<K, V>,
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,
* 计时经 performance.mark / performance.measure 成对标记完成,
* 采样后按名清理标记与测量
* @param caseName 测量项名称
* @param scale 规模档位
* @param run 单次被测量的操作
*/
function measureCase(
caseName: string,
scale: string,
run: () => void
): PerfRecord {
for (let i = 0; i < WARMUP_RUNS; i++) {
run();
}
// 必须先打标记再测量:对不存在的标记做 measure 会抛错;
// 标记还必须逐样本清理,否则同名标记会被解析成首次位置而得到累计耗时
const startTag = 'perf:' + caseName + ':' + scale + ':start';
const endTag = 'perf:' + caseName + ':' + scale + ':end';
const measureName = 'perf:' + caseName + ':' + scale;
const samples: number[] = [];
for (let i = 0; i < SAMPLE_RUNS; i++) {
globalThis.performance.mark(startTag);
run();
globalThis.performance.mark(endTag);
const measure = globalThis.performance.measure(
measureName,
startTag,
endTag
);
samples.push(measure.duration);
globalThis.performance.clearMarks(startTag);
globalThis.performance.clearMarks(endTag);
globalThis.performance.clearMeasures(measureName);
}
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<IEnemyAttr> {
return new Enemy<IEnemyAttr>('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<IHeroAttr> {
return {
num,
id,
category,
name: id,
text: id,
hideInToolbox: false,
effect: { useEvent: null, useEffect: () => {}, canUse: () => false },
equip: {
slots: [0],
animate: 'sword',
value: {},
percentage: {},
loadEvent: null,
unloadEvent: null
}
};
}
/**
* 向图块与道具存储注册一个道具定义,
* 顺序不可颠倒:装备实例化依赖 itemStore 中已存在的定义
* @param state 顶层数据端状态
* @param item 道具定义
*/
function registerItem(state: CoreState, item: IItemRawData<IHeroAttr>): 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('event');
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);
});
});
}
}
});