template/packages-user/data-state/test/saveablesReal.perf.ts
unanmed 6c4d3709d3 test(06-18): migrate 5 existing perf files to performance.mark/measure
- measureCase sampling loop now pairs mark(startTag)/mark(endTag) with
  measure(measureName, startTag, endTag) and clears marks/measures per sample
- tag names derive from caseName+scale so they never collide across cases
- WARMUP_RUNS/SAMPLE_RUNS/median/min/p95/toFixed(3)/columns unchanged
2026-09-16 20:26:42 +08:00

469 lines
16 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 性能测量:真实 13×13 魔塔地图下 CoreState 存读档耗时,只记录不断言
import { afterAll, describe, it, vi } from 'vitest';
import {
type IEnemyAttr,
type IHeroAttr,
type IItemRawData,
type IMapRawData,
ItemCategory,
SaveCompression,
TileType
} from '@user/data-common';
import { Enemy, ValueModifier } from '@user/data-base';
import { CoreState, createCoreState } from '../src/core';
import floorDataset from './fixtures/floors.json';
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;
/** 存读档覆盖的地图数量,确定性取数据集的前 1 / 5 / 13 张 */
const MAP_SCALES = [1, 5, 13] as const;
/** 清除规则命中的图块编号:普通门 / 资源 / 怪物 / 机关门 */
const CLEAR_CODES: readonly number[] = [2, 3, 4, 6];
/** 固定侧负载flag 条目数量 */
const FLAG_COUNT = 50;
/** 固定侧负载:装备道具编号,共 3 种 */
const EQUIP_ITEM_NUMS: readonly number[] = [3000, 3001, 3002];
/** 固定侧负载:装备实例编号,共 4 件,其中 3000 出现两次 */
const EQUIP_INSTANCE_NUMS: readonly number[] = [3000, 3001, 3002, 3000];
/** 固定侧负载4 件装备占用的数字槽位,互不重复才能同时装备 */
const EQUIPPED_SLOTS: readonly number[] = [0, 1, 2, 3];
/** 固定侧负载:消耗品编号起点 */
const OTHER_ITEM_BASE = 3020;
/** 固定侧负载:消耗品种类数,编号 3020..3036,与 3 种装备合共 20 种道具 */
const OTHER_ITEM_KINDS = 17;
/** 固定侧负载:手写修饰器数量,加上 4 件装备各贡献 1 个即 20 个修饰器 */
const MANUAL_MODIFIER_COUNT = 16;
/** 固定侧负载:录像步数,按命令码 0..7 混合循环 */
const REPLAY_STEPS = 1000;
/** 手写修饰器轮转分布的勇士数值属性 */
const HERO_ATTR_NAMES: readonly (keyof IHeroAttr)[] = [
'hp',
'hpmax',
'atk',
'def',
'mdef',
'mana',
'manamax',
'money',
'exp'
];
/** 勇士数值属性的键类型,用于装备 value/percentage 的构造 */
type HeroKey = SelectKey<IHeroAttr, number>;
/** 一档压缩档位的枚举值与表格展示名 */
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 IFloorEntry {
/** 13×13 的真实图块矩阵 */
readonly map: number[][];
/** 数据集记录的 [宽, 高],矩阵已自洽,故不参与注入 */
readonly size: number[];
}
/** 数据集文件的松散视图,用于只保留需要的字段 */
interface IFloorDataset {
/** 数据集编号,仅用于对齐真实文件结构 */
readonly datasetId: number;
/** 楼层 id 到条目的映射 */
readonly data: Record<string, IFloorEntry>;
}
const dataset: IFloorDataset = floorDataset;
/** 只保留 map 与 size 的真实地图条目,键为楼层 id */
const FLOOR_DATA: Record<string, IFloorEntry> = {};
for (const [name, entry] of Object.entries(dataset.data)) {
FLOOR_DATA[name] = { map: entry.map, size: entry.size };
}
/** 数据集声明顺序即稳定顺序,故「前 N 张」是确定性的选择 */
const FLOOR_NAMES: readonly string[] = Object.keys(FLOOR_DATA);
/** 数据集中出现过的全部非零图块编号,升序去重,与地图数量无关 */
const TILE_CODES: readonly number[] = collectTileCodes();
/** 单条性能记录,字段与控制台表格列一一对应 */
interface PerfRecord {
/** 测量项名称 */
case: string;
/** 规模档位 */
scale: string;
/** 中位耗时,单位毫秒 */
'median ms': number;
/** 最小耗时,单位毫秒 */
'min ms': number;
/** 95 分位耗时,单位毫秒 */
'p95 ms': number;
}
/** 本次运行累积的性能记录,由 afterAll 统一打印 */
const records: PerfRecord[] = [];
/**
* 收集数据集中出现过的全部非零图块编号
* @returns 升序去重后的编号数组
*/
function collectTileCodes(): number[] {
const codes = new Set<number>();
for (const entry of Object.values(FLOOR_DATA)) {
for (const row of entry.map) {
for (const code of row) {
if (code !== 0) codes.add(code);
}
}
}
return [...codes].sort((left, right) => left - right);
}
/**
* 把二维矩阵转为扁平数组IMapRawData.map 要求扁平存储
* @param matrix 二维图块矩阵
*/
function toFlat(matrix: number[][]): number[] {
return matrix.flat();
}
/**
* 把实时地图中的门 / 资源 / 怪物 / 机关门置空,保留空地 / 墙壁 / 入口
* @param flat 扁平图块数组
*/
function clearCodes(flat: readonly number[]): number[] {
return flat.map(code => (CLEAR_CODES.includes(code) ? 0 : code));
}
/**
* 测量一次用例:先预热若干次,再采样后取中位数、最小值与 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;
}
/**
* 注册数据集中出现过的全部图块编号,与地图数量无关
* 动机是夹具真实性(真实游戏里这些编号都有定义),而非消除某条既有告警
* @param state 顶层数据端状态
*/
function registerTiles(state: CoreState): void {
for (const num of TILE_CODES) {
state.tileStore.addTile({
num,
id: 'perf-tile-' + num.toString(),
events: {},
type: TileType.Terrain,
pass: { onlyEvents: false, inPass: 15, outPass: 15 },
eventPass: true
});
}
}
/**
* 构造一个完整的合成道具定义,装备与消耗品共用
* value 必须走 [HeroKey, number][] 再 new Map
* 直接写 new Map([['atk', 1]]) 会被推断成 Map<string, number>
* @param num 道具编号
* @param id 道具 id
* @param category 道具分类
* @param value 装备数值加成条目
*/
function createItemRaw(
num: number,
id: string,
category: ItemCategory,
value: [HeroKey, number][] = []
): IItemRawData<IHeroAttr> {
return {
num,
id,
category,
name: id,
text: id,
hideInToolbox: false,
effect: { useEvent: null, useEffect: () => {}, canUse: () => false },
equip: {
slots: [...EQUIPPED_SLOTS],
animate: 'sword',
value: new Map(value),
percentage: new Map(),
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);
}
/** 构造一个带合成属性的怪物模板对象 */
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()
});
}
/**
* 写入一步混合录像,参数与命令语义匹配,
* record 只写录像数组、不校验命令是否注册
* @param state 顶层数据端状态
* @param step 步序号,按 0..7 轮转命令码
*/
function recordReplayStep(state: CoreState, step: number): void {
const code = step % 8;
if (code < 4) {
state.replaySystem.record(code);
} else if (code === 4) {
state.replaySystem.record(code, 2, 3);
} else if (code === 5) {
state.replaySystem.record(code, 3000 + (step % 20));
} else if (code === 6) {
state.replaySystem.record(code, 0, 0, true);
} else {
state.replaySystem.record(code, 0);
}
}
/**
* 写入每个 case 逐字相同的固定真实侧负载:
* 50 个 flag、20 种道具、4 件已装备实例、20 个修饰器、
* 1000 步混合录像,以及只与参考一致的怪物基线
* @param state 顶层数据端状态
*/
function seedSideLoad(state: CoreState): void {
for (let i = 0; i < FLAG_COUNT; i++) {
state.flags.setFieldValue('perf-flag-' + i.toString(), i);
}
// 每种装备恰好 1 个 value 条目,故每件实例只贡献 1 个修饰器
for (const num of EQUIP_ITEM_NUMS) {
registerItem(
state,
createItemRaw(
num,
'perf-equip-' + num.toString(),
ItemCategory.Equipment,
[['atk', 1]]
)
);
}
for (let i = 0; i < OTHER_ITEM_KINDS; i++) {
const num = OTHER_ITEM_BASE + i;
registerItem(
state,
createItemRaw(
num,
'perf-item-' + num.toString(),
ItemCategory.Consumable
)
);
}
// 槽位互不重复才能同时装备 4 件,同一槽位的旧装备会被自动卸下
for (let i = 0; i < EQUIP_INSTANCE_NUMS.length; i++) {
const uid = state.hero.items.equipment.add(EQUIP_INSTANCE_NUMS[i]);
state.hero.equip.equip(uid, EQUIPPED_SLOTS[i]);
}
// 消耗品件数各不相同,模拟真实背包
for (let i = 0; i < OTHER_ITEM_KINDS; i++) {
state.hero.items.addItem(OTHER_ITEM_BASE + i, 1 + (i % 5));
}
// 每次都用新实例注册,避免同一修饰器被重复挂载
state.hero.registerModifier('@system/value', () => new ValueModifier(5));
for (let i = 0; i < MANUAL_MODIFIER_COUNT; i++) {
state.hero.createAndInsertModifier(
'@system/value',
HERO_ATTR_NAMES[i % HERO_ATTR_NAMES.length]
);
}
// 最后写入录像,避免 equip 内部的 record 混进这 1000 步
for (let i = 0; i < REPLAY_STEPS; i++) {
recordReplayStep(state, i);
}
// 与参考一致的怪物模板不会进入 dirtySet故存档里没有怪物条目
state.enemyManager.addPrefab(createEnemy());
state.enemyManager.compareWith(new Map([[1, createEnemy()]]));
}
/**
* 构造存读档夹具:注册图块后注入前若干张真实地图,
* 再一次性建立参考基线。实时内容为清除后的矩阵,参考为原始矩阵
* 侧负载与地图注入次序无关,但每个 case 都必须逐字相同地执行一遍
* @param mapCount 注入的真实地图数量
*/
function createRealMapFixture(mapCount: number): CoreState {
const state = createCoreState();
registerTiles(state);
seedSideLoad(state);
const reference = new Map<string, Map<number, Uint32Array>>();
for (let i = 0; i < mapCount; i++) {
const floorId = FLOOR_NAMES[i];
const entry = FLOOR_DATA[floorId];
const original = toFlat(entry.map);
const raw: IMapRawData = {
floorId,
width: entry.map[0].length,
map: { 0: clearCodes(original) },
layerAlias: { 0: 'bg' },
events: { 0: {} }
};
state.maps.fromRaw(raw);
// saveState 会跳过非 active 楼层,故每张注入的地图都要激活
state.maps.setMapActiveStatus(floorId, true);
reference.set(floorId, new Map([[0, new Uint32Array(original)]]));
}
// 一次性守卫:必须在全部地图建好之后、且只调用一次
state.maps.compareWith(reference);
return state;
}
afterAll(() => {
console.table(records);
});
describe('真实地图存读档性能', () => {
for (const mapCount of MAP_SCALES) {
for (const compression of COMPRESSIONS) {
const label = `${mapCount}/${compression.label}`;
// 覆盖前若干张真实地图在该压缩档下的一次完整存档
it(`saves ${label}`, () => {
const state = createRealMapFixture(mapCount);
measureCase('存档', label, () => {
state.saveState(compression.value);
});
});
// 覆盖前若干张真实地图在该压缩档下的一次读档,快照在预热前取一次
it(`loads ${label}`, () => {
const state = createRealMapFixture(mapCount);
const snapshot = state.saveState(compression.value);
measureCase('读档', label, () => {
state.loadState(snapshot, compression.value);
});
});
// 覆盖前若干张真实地图在该压缩档下的一次存读档往返
it(`round trips ${label}`, () => {
const state = createRealMapFixture(mapCount);
measureCase('往返', label, () => {
const snapshot = state.saveState(compression.value);
state.loadState(snapshot, compression.value);
});
});
}
}
});