template/packages-user/data-system/src/combat/damage.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

310 lines
9.6 KiB
TypeScript
Raw 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.

// 性能测量:临界计算(calculateCritical)完整枚举随重复次数的耗时,只记录不断言
import { afterAll, beforeAll, describe, it, vi } from 'vitest';
import { type ITileLocator } from '@motajs/common';
import { type IReadonlyEnemy, type IStateBase } from '@user/data-base';
import {
type CriticalableHeroStatus,
type IDamageCalculator,
type IEnemyContext,
type IEnemyDamageInfoBase,
type IEnemyView,
type IReadonlyEnemyHandler
} from './types';
vi.hoisted(() => {
vi.stubGlobal('main', { replayChecking: true });
vi.stubGlobal('location', { origin: 'http://localhost' });
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;
};
Map.prototype.getOrInsert ??= function <K, V>(
this: Map<K, V>,
key: K,
defaultValue: V
): V {
const existing = this.get(key);
if (existing !== undefined) return existing;
this.set(key, defaultValue);
return defaultValue;
};
});
interface TestEnemyAttr {
/** 怪物生命值 */
hp: number;
/** 怪物攻击力 */
atk: number;
/** 怪物防御力 */
def: number;
}
interface TestHeroAttr {
/** 勇士生命值 */
hp: number;
/** 勇士攻击力 */
atk: number;
/** 勇士防御力 */
def: number;
}
interface TestModules {
DamageContext: typeof import('./damage').DamageContext;
HeroAttribute: typeof import('@user/data-base').HeroAttribute;
Enemy: typeof import('@user/data-base').Enemy;
MapLocIndexer: typeof import('@user/data-common').MapLocIndexer;
}
let modules: TestModules;
beforeAll(async () => {
vi.stubGlobal('main', { replayChecking: true });
vi.stubGlobal('location', { origin: 'http://localhost' });
const damageModule = await import('./damage');
const baseModule = await import('@user/data-base');
const commonModule = await import('@user/data-common');
modules = {
DamageContext: damageModule.DamageContext,
HeroAttribute: baseModule.HeroAttribute,
Enemy: baseModule.Enemy,
MapLocIndexer: commonModule.MapLocIndexer
};
});
/** 预热次数,不计入采样 */
const WARMUP_RUNS = 3;
/** 采样次数,取排序后的下中位数 */
const SAMPLE_RUNS = 20;
/** 临界计算覆盖的重复枚举次数 */
const CRITICAL_SCALES = [1000, 10000, 50000] as const;
/** 单条性能记录,字段与控制台表格列一一对应 */
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;
}
/**
* 固定伤害计算器:伤害区间由 100 按每点攻击 10 递减到 0,临界上界固定为 10
*/
class PerfCalculator implements IDamageCalculator<TestEnemyAttr, TestHeroAttr> {
calculate(
handler: IReadonlyEnemyHandler<TestEnemyAttr, TestHeroAttr>
): IEnemyDamageInfoBase {
const atk = handler.hero.getBaseAttribute('atk');
return {
damage: Math.max(0, 100 - atk * 10),
turn: 2
};
}
getCriticalLimit(
_handler: IReadonlyEnemyHandler<TestEnemyAttr, TestHeroAttr>,
_attribute: CriticalableHeroStatus<TestHeroAttr>
): number {
return 10;
}
}
type TestHeroAttribute = import('@user/data-base').HeroAttribute<TestHeroAttr>;
interface CriticalFixture {
/** 怪物上下文假对象 */
context: IEnemyContext<TestEnemyAttr, TestHeroAttr>;
/** 固定伤害计算器 */
calculator: PerfCalculator;
/** 勇士属性对象,攻击力固定为 0 */
hero: TestHeroAttribute;
/** 怪物视图假对象 */
view: IEnemyView<TestEnemyAttr>;
}
/**
* 创建临界计算测量夹具,怪物上下文只实现被测路径所需的最小接口
*/
function createCriticalFixture(): CriticalFixture {
const indexer = new modules.MapLocIndexer();
indexer.setWidth(4);
const hero = new modules.HeroAttribute<TestHeroAttr>({
hp: 100,
atk: 0,
def: 0
});
const origin = new modules.Enemy<TestEnemyAttr>('perf-enemy', 1, {
hp: 30,
atk: 8,
def: 2
});
const computed = origin.clone();
const locators = new Map<IEnemyView<TestEnemyAttr>, ITileLocator>();
const computedViews = new Map<
IReadonlyEnemy<TestEnemyAttr>,
IEnemyView<TestEnemyAttr>
>();
const view: IEnemyView<TestEnemyAttr> = {
context: {} as never,
reset: () => {},
getBaseEnemy: () => origin,
getComputedEnemy: () => computed,
getModifiableEnemy: () => origin,
markDirty: () => {}
};
locators.set(view, { x: 2, y: 3 });
computedViews.set(computed, view);
const context = {
state: {} as IStateBase,
indexer,
getBindedHero: () => hero,
getEnemyLocatorByView: (target: IEnemyView<TestEnemyAttr>) =>
locators.get(target) ?? null,
getViewByComputed: (target: IReadonlyEnemy<TestEnemyAttr>) =>
computedViews.get(target) ?? null
} as never;
return {
context,
calculator: new PerfCalculator(),
hero,
view
};
}
afterAll(() => {
console.table(records);
});
describe('临界计算性能', () => {
// 覆盖 1000 次完整临界枚举的重复耗时
it('measures 1000 critical enumerations', () => {
const fixture = createCriticalFixture();
const damageContext = new modules.DamageContext(
fixture.context,
fixture.calculator,
fixture.hero
);
const count = CRITICAL_SCALES[0];
measureCase('临界计算', String(count), () => {
for (let i = 0; i < count; i++) {
for (const _ of damageContext.calculateCritical(
fixture.view,
'atk'
)) {
// 必须完整消费生成器,否则测不到临界枚举的真实开销
}
}
});
});
// 覆盖 10000 次完整临界枚举的重复耗时
it('measures 10000 critical enumerations', () => {
const fixture = createCriticalFixture();
const damageContext = new modules.DamageContext(
fixture.context,
fixture.calculator,
fixture.hero
);
const count = CRITICAL_SCALES[1];
measureCase('临界计算', String(count), () => {
for (let i = 0; i < count; i++) {
for (const _ of damageContext.calculateCritical(
fixture.view,
'atk'
)) {
// 必须完整消费生成器,否则测不到临界枚举的真实开销
}
}
});
});
// 覆盖 50000 次完整临界枚举的重复耗时
it('measures 50000 critical enumerations', () => {
const fixture = createCriticalFixture();
const damageContext = new modules.DamageContext(
fixture.context,
fixture.calculator,
fixture.hero
);
const count = CRITICAL_SCALES[2];
measureCase('临界计算', String(count), () => {
for (let i = 0; i < count; i++) {
for (const _ of damageContext.calculateCritical(
fixture.view,
'atk'
)) {
// 必须完整消费生成器,否则测不到临界枚举的真实开销
}
}
});
});
});