diff --git a/packages-user/data-base/src/hero/attribute.perf.ts b/packages-user/data-base/src/hero/attribute.perf.ts new file mode 100644 index 0000000..e4b5509 --- /dev/null +++ b/packages-user/data-base/src/hero/attribute.perf.ts @@ -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 ( + 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; + }; +}); + +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 { + 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 { + return new PerfModifier(this.value, this.priority); + } +} + +interface AttributeFixture { + /** 被测勇士属性对象 */ + attribute: HeroAttribute; + /** 已挂载的全部修饰器,按挂载顺序排列,采样时按轮次取用 */ + modifiers: PerfModifier[]; +} + +/** + * 创建属性计算夹具:每个属性都是独立新实例,并按顺序均匀分布到 10 个属性上 + * @param count 挂载的修饰器数量 + */ +function createAttributeFixture(count: number): AttributeFixture { + const attribute = new HeroAttribute({ ...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); + } + } + }); + }); +}); diff --git a/packages-user/data-system/src/combat/context.perf.ts b/packages-user/data-system/src/combat/context.perf.ts new file mode 100644 index 0000000..f7a8eda --- /dev/null +++ b/packages-user/data-system/src/combat/context.perf.ts @@ -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 ( + 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; + }; + Map.prototype.getOrInsert ??= function ( + this: Map, + 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 { + return { code, clone: () => createSpecial(code) } as never; +} + +/** + * 创建一个最小怪物对象,属性固定为合成值 + * @param id 怪物 id + */ +function createEnemy(id: string): IEnemy { + return new modules.Enemy(id, 1, { + hp: 10, + atk: 2, + def: 0 + }); +} + +/** + * 测试用光环视图:只修改怪物攻击力,且不参与特殊属性效果的生成 + */ +class PerfAura implements IAuraView { + /** 此光环是否可能修改基础属性 */ + readonly couldApplyBase: boolean = true; + /** 此光环是否可能修改特殊属性,固定为否以避免触发特殊属性效果链路 */ + readonly couldApplySpecial: boolean = false; + + constructor( + readonly priority: number, + readonly range: IRange + ) {} + + getRangeParam(): any { + return undefined; + } + + apply(handler: IEnemyHandler): void { + handler.enemy.addAttribute('atk', 1); + } + + applySpecial(): IEnemySpecialModifier | null { + return null; + } +} + +/** + * 测试用光环转换器:按代码一对一命中并缓存光环实例, + * 使光环拓扑固定为全局光环 + 特殊属性代码数量,与怪物数量无关 + */ +class PerfConverter implements IAuraConverter { + /** 特殊属性代码 -> 已缓存的光环实例 */ + private readonly cache: Map = new Map(); + + shouldConvert(special: ISpecial): boolean { + return SPECIAL_CODES.includes(special.code); + } + + convert(special: ISpecial): IEnemyAuraView { + 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; + +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( + {} as IStateBase + ); + context.resize(side, side); + const hero = new modules.HeroAttribute({ + 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(); + }); + }); +});