diff --git a/packages-user/data-state/src/enemy/aura.test.ts b/packages-user/data-state/src/enemy/aura.test.ts new file mode 100644 index 0000000..7a930f1 --- /dev/null +++ b/packages-user/data-state/src/enemy/aura.test.ts @@ -0,0 +1,394 @@ +// 测试光环转换器:光环范围与参数选择、属性加成结算、支援光环注册与守卫定位符添加 +import { beforeAll, describe, expect, it, vi } from 'vitest'; +import { type ITileLocator } from '@motajs/common'; +import { type IEnemyAttr, type IHeroAttr } from '@user/data-common'; +import { + type IEnemyContext, + type IEnemyView, + type IReadonlyEnemyHandler, + type IEnemyHandler +} from '@user/data-system'; +import { + type IEnemy, + type IReadonlyEnemy, + type ISpecial +} from '@user/data-base'; +import { type IHaloValue } from './special'; + +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 TestModules { + CommonAura: typeof import('./aura').CommonAura; + CommonAuraConverter: typeof import('./aura').CommonAuraConverter; + GuardAura: typeof import('./aura').GuardAura; + GuardAuraConverter: typeof import('./aura').GuardAuraConverter; + FullRange: typeof import('@motajs/common').FullRange; + RectRange: typeof import('@motajs/common').RectRange; + ManhattanRange: typeof import('@motajs/common').ManhattanRange; +} + +let modules: TestModules; + +beforeAll(async () => { + vi.stubGlobal('main', { replayChecking: true }); + vi.stubGlobal('location', { origin: 'http://localhost' }); + const auraModule = await import('./aura'); + const motaModule = await import('@motajs/common'); + modules = { + CommonAura: auraModule.CommonAura, + CommonAuraConverter: auraModule.CommonAuraConverter, + GuardAura: auraModule.GuardAura, + GuardAuraConverter: auraModule.GuardAuraConverter, + FullRange: motaModule.FullRange, + RectRange: motaModule.RectRange, + ManhattanRange: motaModule.ManhattanRange + }; +}); + +interface FakeEnemy { + /** 可写怪物对象 */ + enemy: IEnemy; + /** 当前怪物属性,供断言使用 */ + attrs: IEnemyAttr; +} + +/** + * 创建一个只实现光环所需能力的内联假怪物 + * @param attrs 覆盖的怪物属性 + */ +function createEnemy(attrs: Partial = {}): FakeEnemy { + const values: IEnemyAttr = { + hp: 20, + atk: 8, + def: 5, + money: 2, + exp: 3, + point: 1, + guard: new Set(), + ...attrs + }; + const enemy = { + id: 'test-enemy', + code: 1, + getSpecial: () => null, + hasSpecial: () => false, + iterateSpecials: () => [], + getAttribute: (key: string) => (values as never)[key], + cloneAttributes: () => structuredClone(values), + clone: () => enemy, + addSpecial: () => {}, + deleteSpecial: () => {}, + setAttribute: (key: string, value: unknown) => { + (values as never)[key] = value; + }, + addAttribute: (key: string, value: number) => { + (values as never)[key] += value; + }, + copyFrom: () => {}, + saveState: () => ({ attrs: structuredClone(values), specials: new Map() }), + loadState: () => {} + } as never; + return { enemy, attrs: values }; +} + +/** + * 创建一个只携带代码与数值的内联假特殊属性 + * @param code 特殊属性代码 + * @param value 特殊属性数值 + */ +function createSpecial(code: number, value: T): ISpecial { + return { code, value } as never; +} + +/** + * 创建一个只实现视图反查的内联假怪物上下文 + * @param resolve 根据计算后怪物对象查找视图的回调 + */ +function createContext( + resolve?: (enemy: IReadonlyEnemy) => IEnemyView | null +): IEnemyContext { + return { + width: 8, + height: 8, + getViewByComputed: (enemy: IReadonlyEnemy) => + resolve ? resolve(enemy) : null + } as never; +} + +/** + * 创建一个只用于占据视图位置的内联假视图 + */ +function createView(): IEnemyView { + return {} as never; +} + +/** + * 组装一个光环转换所需的最小只读信息对象 + * @param enemy 怪物对象 + * @param locator 怪物定位符 + */ +function createReadonlyHandler( + enemy: IReadonlyEnemy, + locator: ITileLocator +): IReadonlyEnemyHandler { + return { enemy, locator } as never; +} + +/** + * 组装一个光环结算所需的最小可写信息对象 + * @param enemy 可写怪物对象 + * @param locator 怪物定位符 + */ +function createWritableHandler( + enemy: IEnemy, + locator: ITileLocator +): IEnemyHandler { + return { enemy, locator } as never; +} + +/** + * 构造一个完整的光环数值 + * @param overrides 覆盖的光环字段 + */ +function createHalo(overrides: Partial = {}): IHaloValue { + return { + haloRange: 0, + haloSquare: false, + hpBuff: 0, + atkBuff: 0, + defBuff: 0, + ...overrides + }; +} + +describe('CommonAuraConverter', () => { + // 验证只有代码 25 的特殊属性会被光环转换器接管 + it('converts only special code 25', () => { + const converter = new modules.CommonAuraConverter(); + const halo = createSpecial(25, createHalo()); + const guard = createSpecial(26, undefined); + + expect(converter.shouldConvert(halo, {} as never)).toBe(true); + expect(converter.shouldConvert(guard, {} as never)).toBe(false); + }); + + // 验证转换结果为携带怪物、特殊属性与定位符的光环视图 + it('converts a code 25 special into a common aura', () => { + const converter = new modules.CommonAuraConverter(); + const { enemy } = createEnemy(); + const locator: ITileLocator = { x: 3, y: 4 }; + const special = createSpecial(25, createHalo()); + + const aura = converter.convert( + special, + createReadonlyHandler(enemy, locator) + ); + + expect(aura).toBeInstanceOf(modules.CommonAura); + expect(aura.enemy).toBe(enemy); + expect(aura.locator).toBe(locator); + expect(aura.special).toBe(special); + }); +}); + +describe('CommonAura range', () => { + // 验证光环范围不大于 0 时使用全图范围且无范围参数 + it('uses the full range when the halo range is not positive', () => { + const { enemy } = createEnemy(); + const aura = new modules.CommonAura( + enemy, + createSpecial(25, createHalo({ haloRange: 0 })), + { x: 3, y: 4 } + ); + + expect(aura.range).toBeInstanceOf(modules.FullRange); + expect(aura.getRangeParam()).toBeUndefined(); + }); + + // 验证九宫格光环使用以定位符为中心的矩形范围参数 + it('uses a centered rect for a square halo', () => { + const { enemy } = createEnemy(); + const aura = new modules.CommonAura( + enemy, + createSpecial( + 25, + createHalo({ haloRange: 2, haloSquare: true }) + ), + { x: 3, y: 4 } + ); + + expect(aura.range).toBeInstanceOf(modules.RectRange); + expect(aura.getRangeParam()).toEqual({ x: 1, y: 2, w: 5, h: 5 }); + }); + + // 验证十字光环使用以定位符为中心的曼哈顿范围参数 + it('uses a manhattan range for a cross halo', () => { + const { enemy } = createEnemy(); + const aura = new modules.CommonAura( + enemy, + createSpecial( + 25, + createHalo({ haloRange: 2, haloSquare: false }) + ), + { x: 3, y: 4 } + ); + + expect(aura.range).toBeInstanceOf(modules.ManhattanRange); + expect(aura.getRangeParam()).toEqual({ cx: 3, cy: 4, radius: 2 }); + }); +}); + +describe('CommonAura apply', () => { + // 验证生命、攻击与防御加成按基础属性比例取整后加到目标怪物上 + it('adds floored attribute buffs to the target enemy', () => { + const base = createEnemy({ hp: 100, atk: 50, def: 30 }); + const target = createEnemy({ hp: 20, atk: 8, def: 5 }); + const aura = new modules.CommonAura( + base.enemy, + createSpecial( + 25, + createHalo({ hpBuff: 10, atkBuff: 50, defBuff: 20 }) + ), + { x: 0, y: 0 } + ); + + aura.apply(createWritableHandler(target.enemy, { x: 1, y: 0 }), base.enemy); + + expect(target.attrs.hp).toBe(30); + expect(target.attrs.atk).toBe(33); + expect(target.attrs.def).toBe(11); + }); + + // 验证加成为 0 时不修改目标怪物的任何属性 + it('leaves the target untouched when every buff is zero', () => { + const base = createEnemy({ hp: 100, atk: 50, def: 30 }); + const target = createEnemy({ hp: 20, atk: 8, def: 5 }); + const aura = new modules.CommonAura( + base.enemy, + createSpecial(25, createHalo()), + { x: 0, y: 0 } + ); + + aura.apply(createWritableHandler(target.enemy, { x: 1, y: 0 }), base.enemy); + + expect(target.attrs).toMatchObject({ hp: 20, atk: 8, def: 5 }); + }); + + // 验证光环的优先级与修饰能力声明,且不产生特殊属性修饰器 + it('reports its priority, capabilities and no special modifier', () => { + const { enemy } = createEnemy(); + const aura = new modules.CommonAura( + enemy, + createSpecial(25, createHalo()), + { x: 0, y: 0 } + ); + + expect(aura.priority).toBe(25); + expect(aura.couldApplyBase).toBe(true); + expect(aura.couldApplySpecial).toBe(false); + expect(aura.applySpecial()).toBeNull(); + }); +}); + +describe('GuardAura', () => { + // 验证只有代码 26 的特殊属性会被支援光环转换器接管 + it('converts only special code 26', () => { + const converter = new modules.GuardAuraConverter(); + + expect( + converter.shouldConvert(createSpecial(26, undefined), {} as never) + ).toBe(true); + expect( + converter.shouldConvert(createSpecial(25, undefined), {} as never) + ).toBe(false); + }); + + // 验证支援光环的范围参数为以定位符为中心的 3x3 矩形 + it('uses a 3x3 rect centered on the locator', () => { + const { enemy } = createEnemy(); + const aura = new modules.GuardAura( + createContext(() => createView()), + enemy, + createSpecial(26, undefined), + { x: 3, y: 4 } + ); + + expect(aura.getRangeParam()).toEqual({ x: 2, y: 3, w: 3, h: 3 }); + }); + + // 验证支援光环把来源定位符添加到周围怪物的支援集合 + it('adds the source locator to a different target enemy guard set', () => { + const source = createEnemy(); + const target = createEnemy(); + const sourceLocator: ITileLocator = { x: 1, y: 1 }; + const context = createContext(() => createView()); + const aura = new modules.GuardAura( + context, + source.enemy, + createSpecial(26, undefined), + sourceLocator + ); + + aura.apply(createWritableHandler(target.enemy, { x: 2, y: 1 })); + + expect((target.attrs.guard as Set).has(sourceLocator)).toBe( + true + ); + }); + + // 验证支援光环不会把来源定位符添加到自身 + it('does not add the source locator to the source itself', () => { + const source = createEnemy(); + const sourceLocator: ITileLocator = { x: 1, y: 1 }; + const aura = new modules.GuardAura( + createContext(() => createView()), + source.enemy, + createSpecial(26, undefined), + sourceLocator + ); + + aura.apply(createWritableHandler(source.enemy, sourceLocator)); + + expect(source.attrs.guard.size).toBe(0); + }); + + // 验证来源视图不存在时不添加任何支援定位符 + it('does nothing when the source view is missing', () => { + const source = createEnemy(); + const target = createEnemy(); + const aura = new modules.GuardAura( + createContext(() => null), + source.enemy, + createSpecial(26, undefined), + { x: 1, y: 1 } + ); + + aura.apply(createWritableHandler(target.enemy, { x: 2, y: 1 })); + + expect(target.attrs.guard.size).toBe(0); + }); +}); diff --git a/packages-user/data-state/src/enemy/special.test.ts b/packages-user/data-state/src/enemy/special.test.ts new file mode 100644 index 0000000..2b10a37 --- /dev/null +++ b/packages-user/data-state/src/enemy/special.test.ts @@ -0,0 +1,173 @@ +// 测试怪物特殊属性注册:代码 0-27 全量注册、守卫默认值、名称与描述生成 +import { beforeAll, describe, expect, it, vi } from 'vitest'; +import { type ISpecial } from '@user/data-base'; +import { type IHaloValue } from './special'; + +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 TestModules { + registerSpecials: typeof import('./special').registerSpecials; +} + +let modules: TestModules; + +beforeAll(async () => { + vi.stubGlobal('main', { replayChecking: true }); + vi.stubGlobal('location', { origin: 'http://localhost' }); + const specialModule = await import('./special'); + modules = { registerSpecials: specialModule.registerSpecials }; +}); + +type SpecialCreation = (enemy: unknown) => ISpecial; + +interface FakeEnemyManager { + /** 记录注册的属性默认值 */ + defaults: Map; + /** 记录注册的代码与创建函数 */ + specials: Map; + /** 记录每个代码的注册次数 */ + registerCounts: Map; + /** 注册一个特殊属性 */ + registerSpecial(code: number, cons: SpecialCreation): void; + /** 注册一个属性默认值 */ + setAttributeDefaults(name: string, value: unknown): void; +} + +/** + * 创建一个只实现注册接口的内联假怪物管理器 + */ +function createManager(): FakeEnemyManager { + const defaults = new Map(); + const specials = new Map(); + const registerCounts = new Map(); + return { + defaults, + specials, + registerCounts, + registerSpecial: (code, cons) => { + specials.set(code, cons); + registerCounts.set(code, (registerCounts.get(code) ?? 0) + 1); + }, + setAttributeDefaults: (name, value) => { + defaults.set(name, value); + } + }; +} + +/** + * 取出注册表中的一个特殊属性创建函数并实例化 + * @param manager 假怪物管理器 + * @param code 特殊属性代码 + */ +function createSpecial( + manager: FakeEnemyManager, + code: number +): ISpecial { + const creation = manager.specials.get(code); + if (!creation) throw new Error(`special ${code} was not registered`); + return creation(undefined); +} + +describe('registerSpecials', () => { + // 验证代码 0 到 27 每个都被恰好注册一次 + it('registers every special code from 0 to 27 exactly once', () => { + const manager = createManager(); + modules.registerSpecials(manager as never); + + const codes = [...manager.specials.keys()].sort((a, b) => a - b); + const counts = [...manager.registerCounts.values()]; + + expect(codes).toEqual([...Array(28).keys()]); + expect(counts).toEqual(Array(28).fill(1)); + }); + + // 验证注册时写入守卫属性默认值为空集合 + it('registers an empty set default for the guard attribute', () => { + const manager = createManager(); + modules.registerSpecials(manager as never); + + expect(manager.defaults.has('guard')).toBe(true); + expect(manager.defaults.get('guard')).toBeInstanceOf(Set); + expect((manager.defaults.get('guard') as Set).size).toBe(0); + }); + + // 验证每个注册的特殊属性都提供名称与描述方法 + it('provides a name and description accessor for every special', () => { + const manager = createManager(); + modules.registerSpecials(manager as never); + + for (const code of manager.specials.keys()) { + const special = createSpecial(manager, code); + expect(typeof special.getSpecialName).toBe('function'); + expect(typeof special.getDescription).toBe('function'); + } + }); + + // 验证可序列化特殊属性按当前数值生成名称与描述 + it('builds the name and description of a serializable special', () => { + const manager = createManager(); + modules.registerSpecials(manager as never); + const combo = createSpecial(manager, 6); + + expect(combo.getSpecialName()).toBe('4连击'); + combo.setValue(5); + expect(combo.getSpecialName()).toBe('5连击'); + expect(combo.getDescription()).toBe('怪物每回合攻击5次。'); + }); + + // 验证光环特殊属性按范围与加成生成描述 + it('builds the description of the halo special from its value', () => { + const manager = createManager(); + modules.registerSpecials(manager as never); + const halo = createSpecial(manager, 25); + + expect(halo.getSpecialName()).toBe('光环'); + expect(halo.getDescription()).toContain('同楼层所有怪物'); + expect(halo.getDescription()).toContain('线性叠加。'); + + const value: IHaloValue = { + haloRange: 2, + haloSquare: true, + hpBuff: 10, + atkBuff: 0, + defBuff: 0 + }; + halo.setValue(value); + expect(halo.getDescription()).toContain('九宫格2格范围内所有怪物'); + expect(halo.getDescription()).toContain('生命提升10%'); + }); + + // 验证无属性特殊属性提供固定名称与描述 + it('provides a fixed name and description for a none-property special', () => { + const manager = createManager(); + modules.registerSpecials(manager as never); + const firstStrike = createSpecial(manager, 1); + + expect(firstStrike.getSpecialName()).toBe('先攻'); + expect(firstStrike.getDescription()).toBe('怪物首先攻击。'); + }); +});