diff --git a/packages-user/data-state/src/enemy/calculator.test.ts b/packages-user/data-state/src/enemy/calculator.test.ts new file mode 100644 index 0000000..475f86c --- /dev/null +++ b/packages-user/data-state/src/enemy/calculator.test.ts @@ -0,0 +1,551 @@ +// 测试内置伤害计算器:基础伤害、无敌、魔攻、连击、多段、支援、先攻、破甲、反击、净化、吸血、负伤、固伤、仇恨与临界上界 +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 +} from '@user/data-system'; +import { + type IEnemy, + type IReadonlyHeroAttribute, + type ISpecial, + type IStateBase +} from '@user/data-base'; + +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 { + MainDamageCalculator: typeof import('./calculator').MainDamageCalculator; + logger: typeof import('@motajs/common').logger; +} + +let modules: TestModules; + +beforeAll(async () => { + vi.stubGlobal('main', { replayChecking: true }); + vi.stubGlobal('location', { origin: 'http://localhost' }); + const calculatorModule = await import('./calculator'); + const motaModule = await import('@motajs/common'); + modules = { + MainDamageCalculator: calculatorModule.MainDamageCalculator, + logger: motaModule.logger + }; +}); + +interface FakeEnemy { + /** 可写怪物对象 */ + enemy: IEnemy; + /** 当前怪物属性,供断言与后续修改 */ + attrs: IEnemyAttr; +} + +interface FakeEnemyOptions { + /** 覆盖的怪物属性 */ + attrs?: Partial; + /** 怪物特殊属性表,键为代码,值为该特殊属性的数值 */ + specials?: Map; +} + +/** + * 创建一个只实现计算器所需能力的内联假怪物,特殊属性以代码表驱动 + * @param options 怪物属性与特殊属性配置 + */ +function createEnemy(options: FakeEnemyOptions = {}): FakeEnemy { + const specials = options.specials ?? new Map(); + const attrs: IEnemyAttr = { + hp: 20, + atk: 8, + def: 5, + money: 2, + exp: 3, + point: 1, + guard: new Set(), + ...options.attrs + }; + const enemy = { + id: 'test-enemy', + code: 1, + getSpecial: (code: number): ISpecial | null => + specials.has(code) + ? ({ + code, + value: specials.get(code), + deepEqualsTo: () => false + } as ISpecial) + : null, + hasSpecial: (code: number) => specials.has(code), + iterateSpecials: () => [], + getAttribute: (key: string) => (attrs as never)[key], + cloneAttributes: () => structuredClone(attrs), + clone: () => enemy, + addSpecial: () => {}, + deleteSpecial: () => {}, + setAttribute: (key: string, value: unknown) => { + (attrs as never)[key] = value; + }, + addAttribute: (key: string, value: number) => { + (attrs as never)[key] += value; + }, + copyFrom: () => {}, + saveState: () => ({ + attrs: structuredClone(attrs), + specials: new Map() + }), + loadState: () => {} + } as IEnemy; + return { enemy, attrs }; +} + +/** + * 创建一个只提供基础与最终属性读取的内联假勇士 + * @param attrs 覆盖的勇士属性 + */ +function createHero( + attrs: Partial = {} +): IReadonlyHeroAttribute { + const values: IHeroAttr = { + name: 'hero', + hp: 100, + hpmax: 100, + atk: 20, + def: 5, + mdef: 0, + mana: 0, + manamax: 0, + money: 0, + exp: 0, + ...attrs + }; + return { + getBaseAttribute: (name: string) => (values as never)[name], + getFinalAttribute: (name: string) => (values as never)[name] + } as IReadonlyHeroAttribute; +} + +interface FakeStateOptions { + /** 十字架数量,未配置表示背包中没有十字架 */ + cross?: number; + /** flag 字段值表 */ + flags?: Record; +} + +/** + * 创建一个只实现计算器所需状态读取的内联假状态对象 + * @param options 十字架数量与 flag 字段配置 + */ +function createState(options: FakeStateOptions = {}): IStateBase { + const flags = options.flags ?? {}; + return { + hero: { + items: { + getItemState: (id: string) => + id === 'cross' && options.cross !== undefined + ? { count: options.cross } + : null + } + }, + flags: { + getFieldValueDefaults: (name: string, defaultValue: unknown) => + name in flags ? flags[name] : defaultValue + } + } as IStateBase; +} + +/** + * 创建一个只实现定位符查找的内联假地图上下文 + * @param resolve 按定位符查找怪物视图的回调 + */ +function createContext( + resolve?: (locator: ITileLocator) => IEnemyView | null +): IEnemyContext { + return { + width: 8, + height: 8, + getEnemyByLocator: (locator: ITileLocator) => + resolve ? resolve(locator) : null + } as IEnemyContext; +} + +/** + * 将一个计算后怪物对象包装为只提供该对象读取的假视图 + * @param computed 计算后怪物对象 + */ +function createView( + computed: IEnemy +): IEnemyView { + return { + getComputedEnemy: () => computed + } as IEnemyView; +} + +interface HandlerOptions { + /** 怪物对象 */ + enemy: IEnemy; + /** 勇士属性 */ + hero?: IReadonlyHeroAttribute; + /** 状态对象 */ + state?: IStateBase; + /** 地图上下文 */ + context?: IEnemyContext; + /** 怪物定位符 */ + locator?: ITileLocator; +} + +/** + * 组装一个计算器所需的最小只读信息对象 + * @param options 信息对象各字段 + */ +function createHandler( + options: HandlerOptions +): IReadonlyEnemyHandler { + return { + enemy: options.enemy, + context: options.context ?? createContext(), + locator: options.locator ?? { x: 0, y: 0 }, + hero: options.hero ?? createHero(), + state: options.state ?? createState() + } as IReadonlyEnemyHandler; +} + +/** + * 创建一个带特殊属性代码表的怪物属性配置 + * @param entries 特殊属性代码与数值对 + */ +function specialsOf(entries: Array<[number, unknown]>): Map { + return new Map(entries); +} + +/** + * 创建一个计算器实例 + */ +function createCalculator(): InstanceType< + TestModules['MainDamageCalculator'] +> { + return new modules.MainDamageCalculator(); +} + +describe('MainDamageCalculator base and defeat branches', () => { + // 验证无特殊属性时按攻防差计算每轮伤害与回合数 + it('computes damage and turns from the attack and defense difference', () => { + const { enemy } = createEnemy(); + const calculator = createCalculator(); + + const info = calculator.calculate(createHandler({ enemy })); + + expect(info).toEqual({ damage: 3, turn: 2 }); + }); + + // 验证勇士无法破防时返回无敌伤害与 0 回合 + it('returns infinite damage when the hero cannot break the defense', () => { + const { enemy } = createEnemy({ attrs: { def: 20 } }); + const calculator = createCalculator(); + + const info = calculator.calculate(createHandler({ enemy })); + + expect(info).toEqual({ damage: Infinity, turn: 0 }); + }); + + // 验证无敌怪物在没有十字架时不可战胜,持有十字架后恢复正常计算 + it('blocks 无敌 without a cross and allows it with one', () => { + const { enemy } = createEnemy({ specials: specialsOf([[20, undefined]]) }); + const calculator = createCalculator(); + + const blocked = calculator.calculate(createHandler({ enemy })); + const allowed = calculator.calculate( + createHandler({ enemy, state: createState({ cross: 1 }) }) + ); + + expect(blocked).toEqual({ damage: Infinity, turn: 0 }); + expect(allowed).toEqual({ damage: 3, turn: 2 }); + }); + + // 验证魔攻怪物的每轮伤害不减免勇士防御 + it('ignores hero defense for 魔攻', () => { + const { enemy } = createEnemy({ specials: specialsOf([[2, undefined]]) }); + const calculator = createCalculator(); + + const info = calculator.calculate(createHandler({ enemy })); + + expect(info).toEqual({ damage: 8, turn: 2 }); + }); + + // 验证 2连击与 3连击分别把每轮伤害乘以 2 和 3 + it('multiplies enemy damage for 2连击 and 3连击', () => { + const calculator = createCalculator(); + const double = createEnemy({ specials: specialsOf([[4, undefined]]) }); + const triple = createEnemy({ specials: specialsOf([[5, undefined]]) }); + + expect(calculator.calculate(createHandler({ enemy: double.enemy }))).toEqual( + { damage: 6, turn: 2 } + ); + expect(calculator.calculate(createHandler({ enemy: triple.enemy }))).toEqual( + { damage: 9, turn: 2 } + ); + }); + + // 验证多段伤害按特殊属性数值倍乘每轮伤害 + it('multiplies enemy damage by the 多段 value', () => { + const { enemy } = createEnemy({ specials: specialsOf([[6, 4]]) }); + const calculator = createCalculator(); + + const info = calculator.calculate(createHandler({ enemy })); + + expect(info).toEqual({ damage: 12, turn: 2 }); + }); +}); + +describe('MainDamageCalculator support and additive branches', () => { + // 验证支援怪会递归累加其回合与伤害,且同一支援怪只被计算一次 + it('adds the guard turn and damage through recursion', () => { + const guard = createEnemy(); + const { enemy } = createEnemy({ + attrs: { guard: new Set([{ x: 1, y: 0 }]) } + }); + const calls: string[] = []; + const context = createContext(locator => { + calls.push(`${locator.x},${locator.y}`); + return locator.x === 1 ? createView(guard.enemy) : null; + }); + const calculator = createCalculator(); + + const info = calculator.calculate( + createHandler({ enemy, context }) + ); + + expect(info).toEqual({ damage: 12, turn: 4 }); + expect(calls).toEqual(['1,0']); + }); + + // 验证同一计算器连续两次顶层计算互不影响,支援标记不会泄漏 + it('does not leak the in-guard flag across top-level calls', () => { + const guard = createEnemy(); + const { enemy } = createEnemy({ + attrs: { guard: new Set([{ x: 1, y: 0 }]) } + }); + const context = createContext(locator => + locator.x === 1 ? createView(guard.enemy) : null + ); + const calculator = createCalculator(); + const handler = createHandler({ enemy, context }); + + const first = calculator.calculate(handler); + const second = calculator.calculate(handler); + + expect(first).toEqual({ damage: 12, turn: 4 }); + expect(second).toEqual(first); + }); + + // 验证支援怪不存在时告警 137 并跳过该支援 + it('warns 137 when a guard locator has no enemy', () => { + const { enemy } = createEnemy({ + attrs: { guard: new Set([{ x: 2, y: 0 }]) } + }); + const calculator = createCalculator(); + + const result = modules.logger.catch(() => + calculator.calculate(createHandler({ enemy })) + ); + + expect(result.ret).toEqual({ damage: 3, turn: 2 }); + expect(result.info.map(item => item.code)).toContain(137); + }); + + // 验证先攻会额外附加一次每轮伤害 + it('adds one enemy hit for 先攻', () => { + const { enemy } = createEnemy({ specials: specialsOf([[1, undefined]]) }); + const calculator = createCalculator(); + + const info = calculator.calculate(createHandler({ enemy })); + + expect(info).toEqual({ damage: 6, turn: 2 }); + }); + + // 验证破甲按比例附加勇士防御作为伤害 + it('adds a share of hero defense for 破甲', () => { + const { enemy } = createEnemy({ specials: specialsOf([[7, 100]]) }); + const calculator = createCalculator(); + + const info = calculator.calculate(createHandler({ enemy })); + + expect(info).toEqual({ damage: 8, turn: 2 }); + }); + + // 验证伤害最终向下取整 + it('floors the final damage value', () => { + const { enemy } = createEnemy({ specials: specialsOf([[7, 33]]) }); + const calculator = createCalculator(); + + const info = calculator.calculate(createHandler({ enemy })); + + expect(info).toEqual({ damage: 4, turn: 2 }); + }); + + // 验证反击按比例把勇士攻击附加到每轮伤害 + it('adds a share of hero attack for 反击', () => { + const { enemy } = createEnemy({ specials: specialsOf([[8, 50]]) }); + const calculator = createCalculator(); + + const info = calculator.calculate(createHandler({ enemy })); + + expect(info).toEqual({ damage: 13, turn: 2 }); + }); + + // 验证净化按倍数附加勇士魔防,且随后的减伤仍扣除一次魔防 + it('adds hero mdef for 净化 and then subtracts it once', () => { + const { enemy } = createEnemy({ specials: specialsOf([[9, 2]]) }); + const calculator = createCalculator(); + + const info = calculator.calculate( + createHandler({ enemy, hero: createHero({ mdef: 3 }) }) + ); + + expect(info).toEqual({ damage: 6, turn: 2 }); + }); +}); + +describe('MainDamageCalculator vampire and bypass branches', () => { + // 验证吸血按勇士生命上限比例附加伤害,不回复自身时不改变怪物血量 + it('adds vampire damage without healing the enemy by default', () => { + const { enemy } = createEnemy({ + specials: specialsOf([[11, { vampire: 10, add: false }]]) + }); + const calculator = createCalculator(); + + const info = calculator.calculate( + createHandler({ enemy, hero: createHero({ hp: 200 }) }) + ); + + expect(info).toEqual({ damage: 23, turn: 2 }); + }); + + // 验证开启回复后吸血数值加到怪物血量上并延长回合数 + it('adds vampire damage to the enemy health when add is true', () => { + const { enemy } = createEnemy({ + specials: specialsOf([[11, { vampire: 10, add: true }]]) + }); + const calculator = createCalculator(); + + const info = calculator.calculate( + createHandler({ enemy, hero: createHero({ hp: 200 }) }) + ); + + expect(info).toEqual({ damage: 26, turn: 3 }); + }); + + // 验证未开启负伤时负伤害被夹到 0 + it('clamps negative damage to zero when negative damage is disabled', () => { + const { enemy } = createEnemy({ attrs: { atk: 0 } }); + const calculator = createCalculator(); + + const info = calculator.calculate( + createHandler({ enemy, hero: createHero({ mdef: 5 }) }) + ); + + expect(info).toEqual({ damage: 0, turn: 2 }); + }); + + // 验证开启负伤后负伤害被保留 + it('keeps negative damage when negative damage is enabled', () => { + const { enemy } = createEnemy({ attrs: { atk: 0 } }); + const calculator = createCalculator(); + + const info = calculator.calculate( + createHandler({ + enemy, + hero: createHero({ mdef: 5 }), + state: createState({ flags: { enableNegativeDamage: true } }) + }) + ); + + expect(info).toEqual({ damage: -5, turn: 2 }); + }); + + // 验证固伤在扣除魔防之后附加,不受魔防影响 + it('adds 固伤 after the mdef subtraction', () => { + const { enemy } = createEnemy({ specials: specialsOf([[22, 7]]) }); + const calculator = createCalculator(); + + const info = calculator.calculate( + createHandler({ enemy, hero: createHero({ mdef: 2 }) }) + ); + + expect(info).toEqual({ damage: 8, turn: 2 }); + }); + + // 验证仇恨按 flag 字段值附加伤害且不受魔防影响 + it('adds the hatred flag value for 仇恨', () => { + const { enemy } = createEnemy({ specials: specialsOf([[17, undefined]]) }); + const calculator = createCalculator(); + + const info = calculator.calculate( + createHandler({ + enemy, + hero: createHero({ mdef: 2 }), + state: createState({ flags: { hatred: 5 } }) + }) + ); + + expect(info).toEqual({ damage: 6, turn: 2 }); + }); +}); + +describe('MainDamageCalculator critical limit', () => { + // 验证攻击临界上界为怪物防御加生命,坚固怪物则为无穷 + it('returns def plus hp for atk, and Infinity for 坚固', () => { + const normal = createEnemy(); + const solid = createEnemy({ specials: specialsOf([[3, undefined]]) }); + const calculator = createCalculator(); + + expect( + calculator.getCriticalLimit( + createHandler({ enemy: normal.enemy }), + 'atk' + ) + ).toBe(25); + expect( + calculator.getCriticalLimit( + createHandler({ enemy: solid.enemy }), + 'atk' + ) + ).toBe(Infinity); + }); + + // 验证非攻击属性直接返回勇士对应最终属性 + it('returns the hero final attribute for other attributes', () => { + const { enemy } = createEnemy(); + const calculator = createCalculator(); + + const limit = calculator.getCriticalLimit( + createHandler({ enemy, hero: createHero({ def: 7 }) }), + 'def' + ); + + expect(limit).toBe(7); + }); +}); diff --git a/packages-user/data-state/src/enemy/comparer.test.ts b/packages-user/data-state/src/enemy/comparer.test.ts new file mode 100644 index 0000000..8f4211b --- /dev/null +++ b/packages-user/data-state/src/enemy/comparer.test.ts @@ -0,0 +1,153 @@ +// 测试怪物比较器:基础属性全部一致且特殊属性集合深比较相等时判定为相同 +import { beforeAll, describe, expect, it, vi } from 'vitest'; +import { type IEnemyAttr } from '@user/data-common'; +import { type IReadonlyEnemy, type ISpecial } from '@user/data-base'; + +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 { + MainEnemyComparer: typeof import('./comparer').MainEnemyComparer; +} + +let modules: TestModules; + +beforeAll(async () => { + vi.stubGlobal('main', { replayChecking: true }); + vi.stubGlobal('location', { origin: 'http://localhost' }); + const comparerModule = await import('./comparer'); + modules = { + MainEnemyComparer: comparerModule.MainEnemyComparer + }; +}); + +/** + * 创建一个按代码与数值做深比较的内联假特殊属性 + * @param code 特殊属性代码 + * @param value 特殊属性数值 + */ +function createSpecial(code: number, value: unknown): ISpecial { + return { + code, + value, + deepEqualsTo: (other: ISpecial) => + other.code === code && + JSON.stringify(other.value) === JSON.stringify(value) + } as never; +} + +/** + * 创建一个只实现比较器所需读取能力的内联假怪物 + * @param attrs 覆盖的怪物属性 + * @param specials 怪物拥有的特殊属性列表 + */ +function createEnemy( + attrs: Partial = {}, + specials: ISpecial[] = [] +): IReadonlyEnemy { + const values: IEnemyAttr = { + hp: 20, + atk: 8, + def: 5, + money: 2, + exp: 3, + point: 1, + guard: new Set(), + ...attrs + }; + return { + id: 'test-enemy', + code: 1, + getSpecial: (code: number) => + specials.find(special => special.code === code) ?? null, + hasSpecial: (code: number) => + specials.some(special => special.code === code), + iterateSpecials: () => specials, + getAttribute: (key: string) => (values as never)[key], + cloneAttributes: () => structuredClone(values), + clone: () => createEnemy(attrs, specials) + } as never; +} + +describe('MainEnemyComparer', () => { + // 验证基础属性与特殊属性都一致时判定为相同 + it('returns true for identical attributes and specials', () => { + const comparer = new modules.MainEnemyComparer(); + const enemyA = createEnemy({}, [createSpecial(6, 3)]); + const enemyB = createEnemy({}, [createSpecial(6, 3)]); + + expect(comparer.compare(enemyA, enemyB)).toBe(true); + }); + + // 验证任一基础属性不同都会判定为不同 + it('returns false when any base attribute differs', () => { + const comparer = new modules.MainEnemyComparer(); + const base = createEnemy(); + const cases: Array<[keyof IEnemyAttr, number]> = [ + ['hp', 21], + ['atk', 9], + ['def', 6], + ['money', 3], + ['exp', 4], + ['point', 2] + ]; + + for (const [key, value] of cases) { + const other = createEnemy({ [key]: value }); + expect(comparer.compare(base, other)).toBe(false); + } + }); + + // 验证特殊属性数量不同时判定为不同 + it('returns false when the special counts differ', () => { + const comparer = new modules.MainEnemyComparer(); + const enemyA = createEnemy({}, [createSpecial(6, 3)]); + const enemyB = createEnemy({}, [ + createSpecial(6, 3), + createSpecial(7, 0) + ]); + + expect(comparer.compare(enemyA, enemyB)).toBe(false); + }); + + // 验证特殊属性代码集合不同时判定为不同 + it('returns false when the special codes differ', () => { + const comparer = new modules.MainEnemyComparer(); + const enemyA = createEnemy({}, [createSpecial(6, 3)]); + const enemyB = createEnemy({}, [createSpecial(7, 3)]); + + expect(comparer.compare(enemyA, enemyB)).toBe(false); + }); + + // 验证同代码特殊属性数值不同时判定为不同 + it('returns false when a special deep comparison fails', () => { + const comparer = new modules.MainEnemyComparer(); + const enemyA = createEnemy({}, [createSpecial(6, 3)]); + const enemyB = createEnemy({}, [createSpecial(6, 5)]); + + expect(comparer.compare(enemyA, enemyB)).toBe(false); + }); +}); diff --git a/packages-user/data-state/src/enemy/final.test.ts b/packages-user/data-state/src/enemy/final.test.ts new file mode 100644 index 0000000..b390526 --- /dev/null +++ b/packages-user/data-state/src/enemy/final.test.ts @@ -0,0 +1,188 @@ +// 测试怪物最终效果:坚固把防御提升到勇士攻击减一、模仿复制勇士攻防、无相关特殊属性时保持不变 +import { beforeAll, describe, expect, it, vi } from 'vitest'; +import { type IEnemyAttr, type IHeroAttr } from '@user/data-common'; +import { type IEnemyHandler } from '@user/data-system'; +import { + type IEnemy, + type IReadonlyHeroAttribute +} from '@user/data-base'; + +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 { + MainEnemyFinalEffect: typeof import('./final').MainEnemyFinalEffect; +} + +let modules: TestModules; + +beforeAll(async () => { + vi.stubGlobal('main', { replayChecking: true }); + vi.stubGlobal('location', { origin: 'http://localhost' }); + const finalModule = await import('./final'); + modules = { + MainEnemyFinalEffect: finalModule.MainEnemyFinalEffect + }; +}); + +interface FakeEnemy { + /** 可写怪物对象 */ + enemy: IEnemy; + /** 当前怪物属性,供断言使用 */ + attrs: IEnemyAttr; +} + +/** + * 创建一个只实现最终效果所需能力的内联假怪物 + * @param attrs 覆盖的怪物属性 + * @param specials 怪物拥有的特殊属性代码 + */ +function createEnemy( + attrs: Partial = {}, + specials: number[] = [] +): 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: (code: number) => specials.includes(code), + 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 attrs 覆盖的勇士属性 + */ +function createHero( + attrs: Partial = {} +): IReadonlyHeroAttribute { + const values: IHeroAttr = { + name: 'hero', + hp: 100, + hpmax: 100, + atk: 20, + def: 7, + mdef: 0, + mana: 0, + manamax: 0, + money: 0, + exp: 0, + ...attrs + }; + return { + getBaseAttribute: (name: string) => (values as never)[name], + getFinalAttribute: (name: string) => (values as never)[name] + } as never; +} + +/** + * 组装一个最终效果所需的最小信息对象 + * @param enemy 怪物对象 + * @param hero 勇士属性对象 + */ +function createHandler( + enemy: IEnemy, + hero: IReadonlyHeroAttribute = createHero() +): IEnemyHandler { + return { enemy, hero } as never; +} + +describe('MainEnemyFinalEffect', () => { + // 验证最终效果的优先级为 0 + it('reports priority 0', () => { + const effect = new modules.MainEnemyFinalEffect(); + + expect(effect.priority).toBe(0); + }); + + // 验证坚固把低于勇士攻击减一的防御提升到该值 + it('raises defense to hero attack minus one for 坚固', () => { + const { enemy, attrs } = createEnemy({ def: 5 }, [3]); + const effect = new modules.MainEnemyFinalEffect(); + + effect.apply(createHandler(enemy)); + + expect(attrs.def).toBe(19); + }); + + // 验证坚固不会降低已高于勇士攻击减一的防御 + it('keeps a defense already above hero attack minus one for 坚固', () => { + const { enemy, attrs } = createEnemy({ def: 25 }, [3]); + const effect = new modules.MainEnemyFinalEffect(); + + effect.apply(createHandler(enemy)); + + expect(attrs.def).toBe(25); + }); + + // 验证模仿把怪物的攻击与防御改成勇士的最终攻击与防御 + it('copies hero attack and defense for 模仿', () => { + const { enemy, attrs } = createEnemy({ atk: 3, def: 3 }, [10]); + const effect = new modules.MainEnemyFinalEffect(); + + effect.apply(createHandler(enemy)); + + expect(attrs.atk).toBe(20); + expect(attrs.def).toBe(7); + }); + + // 验证没有相关特殊属性时怪物的攻击与防御保持不变 + it('leaves enemies without the effect specials untouched', () => { + const { enemy, attrs } = createEnemy({ atk: 3, def: 3 }, [1, 2]); + const effect = new modules.MainEnemyFinalEffect(); + + effect.apply(createHandler(enemy)); + + expect(attrs.atk).toBe(3); + expect(attrs.def).toBe(3); + }); +});