test(06-16): add enemy context buildup and hero attribute perf suites

- context.perf.ts: 3 cases (50/200/1000 enemies), fixed 10 global auras + 5 special codes, cached aura topology, one buildup per sample
- attribute.perf.ts: 3 cases (10/100/1000 modifiers over 10 attributes), 1000 recalc rounds per sample via markModifierDirty + getFinalAttribute
- warmup 3 + 20 samples, median/min/p95 via console.table, no assertions
This commit is contained in:
unanmed 2026-09-16 15:02:57 +08:00
parent 37bf89bc94
commit 3c59f31e6f
2 changed files with 532 additions and 0 deletions

View File

@ -0,0 +1,243 @@
// 性能测量:勇士属性修饰器重算随修饰器数量的耗时,只记录不断言
import { afterAll, describe, it, vi } from 'vitest';
import { BaseHeroModifier, HeroAttribute } from './attribute';
import { type IHeroModifier } from './types';
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;
};
});
interface PerfHeroAttr {
/** 生命值 */
hp: number;
/** 攻击力 */
atk: number;
/** 防御力 */
def: number;
/** 魔防 */
mdef: number;
/** 金币 */
money: number;
/** 经验 */
exp: number;
/** 点数 */
point: number;
/** 分数 */
score: number;
/** 魔力 */
mana: number;
/** 速度 */
speed: number;
}
/** 固定 10 个数值属性的名称与顺序,修饰器按此顺序均匀分布 */
const ATTR_NAMES: readonly (keyof PerfHeroAttr)[] = [
'hp',
'atk',
'def',
'mdef',
'money',
'exp',
'point',
'score',
'mana',
'speed'
];
/** 10 个数值属性的固定基础值,保证每个 case 的起点一致 */
const BASE_VALUES: PerfHeroAttr = {
hp: 100,
atk: 10,
def: 5,
mdef: 5,
money: 100,
exp: 50,
point: 10,
score: 0,
mana: 50,
speed: 10
};
/** 预热次数,不计入采样 */
const WARMUP_RUNS = 3;
/** 采样次数,取排序后的下中位数 */
const SAMPLE_RUNS = 20;
/** 属性计算覆盖的修饰器数量 */
const MODIFIER_SCALES = [10, 100, 1000] as const;
/** 单次采样内重复的重算轮数 */
const RECALC_ROUNDS = 1000;
/** 单条性能记录,字段与控制台表格列一一对应 */
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;
}
/**
* 测试用数值修饰器,按优先级对数值属性做加法
*/
class PerfModifier extends BaseHeroModifier<number, number> {
readonly type: string = '@perf/value';
readonly priority: number;
/**
* @param value 修饰器数值
* @param priority 修饰器优先级
*/
constructor(value: number, priority: number = 0) {
super(value);
this.priority = priority;
}
modify(value: number): number {
return value + this.value;
}
clone(): IHeroModifier<number, number> {
return new PerfModifier(this.value, this.priority);
}
}
interface AttributeFixture {
/** 被测勇士属性对象 */
attribute: HeroAttribute<PerfHeroAttr>;
/** 已挂载的全部修饰器,按挂载顺序排列,采样时按轮次取用 */
modifiers: PerfModifier[];
}
/**
* 创建属性计算夹具:每个属性都是独立新实例,并按顺序均匀分布到 10 个属性上
* @param count 挂载的修饰器数量
*/
function createAttributeFixture(count: number): AttributeFixture {
const attribute = new HeroAttribute<PerfHeroAttr>({ ...BASE_VALUES });
const modifiers: PerfModifier[] = [];
for (let i = 0; i < count; i++) {
const modifier = new PerfModifier(i);
attribute.addModifier(ATTR_NAMES[i % ATTR_NAMES.length], modifier);
modifiers.push(modifier);
}
return { attribute, modifiers };
}
afterAll(() => {
console.table(records);
vi.unstubAllGlobals();
});
describe('勇士属性计算性能', () => {
// 覆盖 10 个修饰器分布到 10 个属性时,1000 轮重算并读取全部最终属性的耗时
it('measures 10 modifiers across 10 attributes', () => {
const fixture = createAttributeFixture(MODIFIER_SCALES[0]);
measureCase('勇士属性计算', String(MODIFIER_SCALES[0]), () => {
for (let i = 0; i < RECALC_ROUNDS; i++) {
fixture.attribute.markModifierDirty(
fixture.modifiers[i % fixture.modifiers.length]
);
for (const name of ATTR_NAMES) {
fixture.attribute.getFinalAttribute(name);
}
}
});
});
// 覆盖 100 个修饰器分布到 10 个属性时,1000 轮重算并读取全部最终属性的耗时
it('measures 100 modifiers across 10 attributes', () => {
const fixture = createAttributeFixture(MODIFIER_SCALES[1]);
measureCase('勇士属性计算', String(MODIFIER_SCALES[1]), () => {
for (let i = 0; i < RECALC_ROUNDS; i++) {
fixture.attribute.markModifierDirty(
fixture.modifiers[i % fixture.modifiers.length]
);
for (const name of ATTR_NAMES) {
fixture.attribute.getFinalAttribute(name);
}
}
});
});
// 覆盖 1000 个修饰器分布到 10 个属性时,1000 轮重算并读取全部最终属性的耗时
it('measures 1000 modifiers across 10 attributes', () => {
const fixture = createAttributeFixture(MODIFIER_SCALES[2]);
measureCase('勇士属性计算', String(MODIFIER_SCALES[2]), () => {
for (let i = 0; i < RECALC_ROUNDS; i++) {
fixture.attribute.markModifierDirty(
fixture.modifiers[i % fixture.modifiers.length]
);
for (const name of ATTR_NAMES) {
fixture.attribute.getFinalAttribute(name);
}
}
});
});
});

View File

@ -0,0 +1,289 @@
// 性能测量:怪物上下文全量构建(buildup)随怪物数量的耗时,只记录不断言
import { afterAll, beforeAll, describe, it, vi } from 'vitest';
import { type IRange } from '@motajs/common';
import { type IEnemy, type IStateBase, type ISpecial } from '@user/data-base';
import {
type IAuraConverter,
type IAuraView,
type IEnemyAuraView,
type IEnemyHandler,
type IEnemySpecialModifier
} 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 {
EnemyContext: typeof import('./context').EnemyContext;
Enemy: typeof import('@user/data-base').Enemy;
HeroAttribute: typeof import('@user/data-base').HeroAttribute;
FullRange: typeof import('@motajs/common').FullRange;
}
let modules: TestModules;
beforeAll(async () => {
vi.stubGlobal('main', { replayChecking: true });
vi.stubGlobal('location', { origin: 'http://localhost' });
const contextModule = await import('./context');
const baseModule = await import('@user/data-base');
const motaModule = await import('@motajs/common');
modules = {
EnemyContext: contextModule.EnemyContext,
Enemy: baseModule.Enemy,
HeroAttribute: baseModule.HeroAttribute,
FullRange: motaModule.FullRange
};
});
/** 预热次数,不计入采样 */
const WARMUP_RUNS = 3;
/** 采样次数,取排序后的下中位数 */
const SAMPLE_RUNS = 20;
/** 怪物上下文构建覆盖的怪物数量 */
const ENEMY_SCALES = [50, 200, 1000] as const;
/** 固定注册的全局光环数量 */
const GLOBAL_AURA_COUNT = 10;
/** 固定挂载到每个怪物上的 5 个特殊属性代码 */
const SPECIAL_CODES: readonly number[] = [20, 21, 22, 23, 24];
/** 单条性能记录,字段与控制台表格列一一对应 */
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;
}
/**
* 创建一个最小特殊属性对象,只用于驱动光环转换的代码匹配
* @param code 特殊属性代码
*/
function createSpecial(code: number): ISpecial<any> {
return { code, clone: () => createSpecial(code) } as never;
}
/**
* 创建一个最小怪物对象,属性固定为合成值
* @param id 怪物 id
*/
function createEnemy(id: string): IEnemy<TestEnemyAttr> {
return new modules.Enemy<TestEnemyAttr>(id, 1, {
hp: 10,
atk: 2,
def: 0
});
}
/**
* 测试用光环视图:只修改怪物攻击力,且不参与特殊属性效果的生成
*/
class PerfAura implements IAuraView<TestEnemyAttr, any> {
/** 此光环是否可能修改基础属性 */
readonly couldApplyBase: boolean = true;
/** 此光环是否可能修改特殊属性,固定为否以避免触发特殊属性效果链路 */
readonly couldApplySpecial: boolean = false;
constructor(
readonly priority: number,
readonly range: IRange<any>
) {}
getRangeParam(): any {
return undefined;
}
apply(handler: IEnemyHandler<TestEnemyAttr, TestHeroAttr>): void {
handler.enemy.addAttribute('atk', 1);
}
applySpecial(): IEnemySpecialModifier<TestEnemyAttr> | null {
return null;
}
}
/**
* 测试用光环转换器:按代码一对一命中并缓存光环实例,
* 使光环拓扑固定为全局光环 + 特殊属性代码数量,与怪物数量无关
*/
class PerfConverter implements IAuraConverter<TestEnemyAttr, TestHeroAttr> {
/** 特殊属性代码 -> 已缓存的光环实例 */
private readonly cache: Map<number, PerfAura> = new Map();
shouldConvert(special: ISpecial<any>): boolean {
return SPECIAL_CODES.includes(special.code);
}
convert(special: ISpecial<any>): IEnemyAuraView<TestEnemyAttr, any, any> {
const aura = this.cache.getOrInsertComputed(
special.code,
() => new PerfAura(special.code, new modules.FullRange())
);
return aura as never;
}
}
type TestEnemyContext = import('./context').EnemyContext<
TestEnemyAttr,
TestHeroAttr
>;
type TestHeroAttribute = import('@user/data-base').HeroAttribute<TestHeroAttr>;
interface ContextFixture {
/** 被测怪物上下文 */
context: TestEnemyContext;
/** 可修改勇士属性,用于触发全量构建 */
hero: TestHeroAttribute;
}
/**
* 创建怪物上下文构建夹具:固定 10 个全局光环 + 5 个特殊属性代码,
* 并按计数在方形网格内放置怪物
* @param count 放置的怪物数量
*/
function createContextFixture(count: number): ContextFixture {
const side = Math.ceil(Math.sqrt(count));
const context = new modules.EnemyContext<TestEnemyAttr, TestHeroAttr>(
{} as IStateBase
);
context.resize(side, side);
const hero = new modules.HeroAttribute<TestHeroAttr>({
hp: 100,
atk: 0,
def: 0
});
context.bindHero(hero);
context.registerAuraConverter(new PerfConverter());
for (let i = 0; i < GLOBAL_AURA_COUNT; i++) {
// 全局光环优先级互不相同,避免拓扑随怪物数量变化
context.addAura(new PerfAura(i + 1, new modules.FullRange()));
}
for (let i = 0; i < count; i++) {
const enemy = createEnemy('perf-' + i);
enemy.addSpecial(
createSpecial(SPECIAL_CODES[i % SPECIAL_CODES.length])
);
context.setEnemyAt({ x: i % side, y: Math.floor(i / side) }, enemy);
}
return { context, hero };
}
afterAll(() => {
console.table(records);
});
describe('怪物上下文构建性能', () => {
// 覆盖 50 个怪物时单次全量构建的耗时
it('measures a buildup over 50 enemies', () => {
const fixture = createContextFixture(ENEMY_SCALES[0]);
measureCase('怪物上下文构建', String(ENEMY_SCALES[0]), () => {
fixture.context.bindHero(fixture.hero);
fixture.context.buildup();
});
});
// 覆盖 200 个怪物时单次全量构建的耗时
it('measures a buildup over 200 enemies', () => {
const fixture = createContextFixture(ENEMY_SCALES[1]);
measureCase('怪物上下文构建', String(ENEMY_SCALES[1]), () => {
fixture.context.bindHero(fixture.hero);
fixture.context.buildup();
});
});
// 覆盖 1000 个怪物时单次全量构建的耗时
it('measures a buildup over 1000 enemies', () => {
const fixture = createContextFixture(ENEMY_SCALES[2]);
measureCase('怪物上下文构建', String(ENEMY_SCALES[2]), () => {
fixture.context.bindHero(fixture.hero);
fixture.context.buildup();
});
});
});