diff --git a/packages-user/data-base/src/hero/attribute.test.ts b/packages-user/data-base/src/hero/attribute.test.ts index 2d07d22..406830b 100644 --- a/packages-user/data-base/src/hero/attribute.test.ts +++ b/packages-user/data-base/src/hero/attribute.test.ts @@ -1,9 +1,17 @@ -// 测试 HeroAttribute 构件:基础/最终属性、修饰器增删排序、存盘开关、克隆与告警码 108/109 +// 测试 HeroAttribute 构件:基础/最终属性、修饰器增删排序、存盘开关、克隆、自身存读档与告警码 108/109 import { afterAll, describe, expect, it, vi } from 'vitest'; import { BaseHeroModifier, HeroAttribute } from './attribute'; import { logger } from '@motajs/common'; +import { SaveCompression } from '@user/data-common'; import { type IHeroModifier } from './types'; +/** 存读档测试覆盖的三档压缩级别 */ +const SAVE_COMPRESSIONS = [ + SaveCompression.NoCompression, + SaveCompression.LowCompression, + SaveCompression.HighCompression +] as const; + vi.hoisted(() => { vi.stubGlobal('main', { replayChecking: true }); vi.stubGlobal('location', { origin: 'http://localhost' }); @@ -91,6 +99,16 @@ function createAttribute(): HeroAttribute { }); } +interface TestNumericHero { + hp: number; + atk: number; +} + +/** 构造一个仅含数值属性的勇士属性对象,使注册表工厂的具体修饰器可满足泛型签名 */ +function createNumericAttribute(): HeroAttribute { + return new HeroAttribute({ hp: 100, atk: 10 }); +} + describe('HeroAttribute base and final values', () => { // 验证存在修饰器时 set/add/mul/div 修改基础属性后最终属性同步反映 it('reflects base attribute changes in the final attribute', () => { @@ -269,3 +287,92 @@ describe('HeroAttribute cloning and progress', () => { expect(attribute.getFinalAttribute('tag')).toEqual({ id: 'base' }); }); }); + +describe('HeroAttribute same-reference save and load', () => { + // 验证三档压缩下基础值与已注册修饰器均在自身实例上原地恢复 + it('restores base values and modifiers in place across all compressions', () => { + for (const compression of SAVE_COMPRESSIONS) { + const attribute = createNumericAttribute(); + attribute.registerModifier( + '@test/value', + () => new TestModifier(5) + ); + const modifier = attribute.createAndInsertModifier( + '@test/value', + 'atk' + )!; + attribute.set('hp', 88); + const holder = attribute; + + const saved = attribute.saveState(compression); + attribute.set('hp', 1); + attribute.set('atk', 1); + attribute.deleteModifier('atk', modifier); + + attribute.loadState(saved, compression); + + expect(attribute).toBe(holder); + expect(holder.getBaseAttribute('hp')).toBe(88); + expect(holder.getBaseAttribute('atk')).toBe(10); + expect(holder.getFinalAttribute('atk')).toBe(15); + expect([...holder.getModifiers('atk')]).toHaveLength(1); + } + }); + + // 验证读档后基础属性对象仍与存档快照分离(原地重置而非换引用) + it('keeps the restored base values independent from the snapshot', () => { + const attribute = createAttribute(); + attribute.set('hp', 88); + + const saved = attribute.saveState(SaveCompression.NoCompression); + attribute.loadState(saved, SaveCompression.NoCompression); + + expect(attribute.toStructured()).toEqual(saved.values); + expect(attribute.toStructured()).not.toBe(saved.values); + }); + + // 验证存档禁用的修饰器不进入属性存档,读档后不复存在 + it('excludes save-disabled modifiers from the attribute snapshot', () => { + const attribute = createAttribute(); + const disabled = new TestModifier(1); + attribute.addModifier('hp', disabled); + attribute.setModifierSaveEnabled(disabled, false); + + const saved = attribute.saveState(SaveCompression.NoCompression); + expect(saved.modifiers).toHaveLength(0); + + attribute.loadState(saved, SaveCompression.NoCompression); + + expect([...attribute.getModifiers('hp')]).toEqual([]); + expect(attribute.getFinalAttribute('hp')).toBe(100); + }); + + // 验证存档基础值是与活属性分离的深拷贝快照 + it('keeps the saved base values independent from the live attribute', () => { + const attribute = createAttribute(); + attribute.set('hp', 88); + + const saved = attribute.saveState(SaveCompression.NoCompression); + attribute.set('hp', 1); + + expect(saved.values.hp).toBe(88); + expect(attribute.getBaseAttribute('hp')).toBe(1); + }); + + // 验证克隆体复制注册表条目后可独立创建并重建修饰器 + it('carries the modifier registry into cloned attributes', () => { + const attribute = createNumericAttribute(); + attribute.registerModifier('@test/value', () => new TestModifier(5)); + + const clone = attribute.clone({ cloneModifier: false }); + expect( + clone.createAndInsertModifier('@test/value', 'atk') + ).not.toBeNull(); + + const saved = clone.saveState(SaveCompression.NoCompression); + expect(saved.modifiers).toHaveLength(1); + clone.loadState(saved, SaveCompression.NoCompression); + + expect(clone.getFinalAttribute('atk')).toBe(15); + }); +}); diff --git a/packages-user/data-base/src/hero/attribute.ts b/packages-user/data-base/src/hero/attribute.ts index 9ccf130..be69165 100644 --- a/packages-user/data-base/src/hero/attribute.ts +++ b/packages-user/data-base/src/hero/attribute.ts @@ -3,7 +3,10 @@ import { SaveCompression } from '@user/data-common'; import { IHeroAttribute, IHeroAttributeCloneOption, - IHeroModifier + IHeroAttributeSave, + IHeroModifier, + IHeroModifierOwner, + IModifierStateSave } from './types'; import { isNil } from 'lodash-es'; @@ -11,7 +14,7 @@ export abstract class BaseHeroModifier implements IHeroModifier { abstract readonly type: string; abstract readonly priority: number; - owner: IHeroAttribute | null = null; + owner: IHeroModifierOwner | null = null; constructor(private currentValue: V) {} @@ -28,7 +31,7 @@ export abstract class BaseHeroModifier implements IHeroModifier { return this.currentValue; } - bindAttribute(attribute: IHeroAttribute | null): void { + bindAttribute(attribute: IHeroModifierOwner | null): void { this.owner = attribute; } @@ -52,6 +55,11 @@ export class HeroAttribute implements IHeroAttribute { private readonly modifierName: Map = new Map(); /** 当前标记为不存档的修饰器集合 */ private readonly modifierNosave: Set = new Set(); + /** 修饰器工厂函数注册表 */ + private readonly registry: Map< + string, + () => IHeroModifier + > = new Map(); /** 当前勇士最终属性 */ private readonly finalAttribute: THero; @@ -250,6 +258,32 @@ export class HeroAttribute implements IHeroAttribute { return !this.modifierNosave.has(modifier); } + registerModifier( + type: string, + cons: () => IHeroModifier + ): void { + this.registry.set(type, cons); + } + + createModifier(type: string): IHeroModifier | null { + const cons = this.registry.get(type); + if (!cons) { + logger.warn(116, type); + return null; + } + return cons() as IHeroModifier; + } + + createAndInsertModifier( + type: string, + name: K + ): IHeroModifier | null { + const modifier = this.createModifier(type); + if (!modifier) return null; + this.addModifier(name, modifier); + return modifier; + } + //#endregion //#region 属性克隆 @@ -261,6 +295,10 @@ export class HeroAttribute implements IHeroAttribute { const cloned = new HeroAttribute( structuredClone(this.attribute) ); + // 注册表条目随克隆体复制,使克隆属性可独立重建修饰器 + for (const [type, cons] of this.registry) { + cloned.registry.set(type, cons); + } if (!cloneModifier) return cloned; for (const [name, modifiers] of this.modifier) { const arr: IHeroModifier[] = modifiers.map(v => v.clone()); @@ -279,4 +317,56 @@ export class HeroAttribute implements IHeroAttribute { } //#endregion + + //#region 属性存读档 + + saveState(compression: SaveCompression): IHeroAttributeSave { + const modifiers: IModifierStateSave[] = []; + for (const [name, modifier] of this.iterateModifiers()) { + if (!this.getModifierSaveEnabled(modifier)) continue; + modifiers.push({ + name: name as keyof THero, + type: modifier.type, + state: modifier.saveState(compression) + }); + } + return { values: this.toStructured(), modifiers }; + } + + loadState( + state: IHeroAttributeSave, + compression: SaveCompression + ): void { + // 原地修改基础属性以保持同引用,使装备与战斗侧持有的属性引用跨读档始终有效 + for (const key in this.attribute) { + if (Object.prototype.hasOwnProperty.call(state.values, key)) { + continue; + } + delete this.attribute[key as keyof THero]; + } + const values = structuredClone(state.values); + for (const key in values) { + this.attribute[key as keyof THero] = values[key as keyof THero]; + } + + this.modifier.clear(); + this.modifierName.clear(); + this.modifierNosave.clear(); + + for (const save of state.modifiers) { + const cons = this.registry.get(save.type); + // 未注册的修饰器类型沿用既有静默跳过语义,不新增告警 + if (!cons) continue; + const modifier = cons(); + modifier.loadState(save.state, compression); + this.addModifier(save.name, modifier); + } + + // 逐键重算 final,清掉被移除修饰器留下的陈旧值 + for (const key in this.attribute) { + this.recalculateAttribute(key as keyof THero); + } + } + + //#endregion } diff --git a/packages-user/data-base/src/hero/types.ts b/packages-user/data-base/src/hero/types.ts index 238303b..1761481 100644 --- a/packages-user/data-base/src/hero/types.ts +++ b/packages-user/data-base/src/hero/types.ts @@ -13,6 +13,14 @@ import { IPassPredicate } from '../map'; //#region 勇士属性 +export interface IHeroModifierOwner { + /** + * 将指定属性修饰器标记为脏 + * @param modifier 属性修饰器 + */ + markModifierDirty(modifier: IHeroModifier): void; +} + export interface IHeroModifier< H = unknown, // 属性值类型 V = unknown, // 修饰器参数类型 @@ -25,7 +33,7 @@ export interface IHeroModifier< /** 修饰器参数值 */ readonly value: V; /** 当前修饰器所属的勇士属性对象 */ - readonly owner: IHeroAttribute | null; + readonly owner: IHeroModifierOwner | null; /** * 设置修饰器参数值 @@ -42,7 +50,7 @@ export interface IHeroModifier< * 绑定勇士属性对象 * @param attribute 勇士属性对象 */ - bindAttribute(attribute: IHeroAttribute | null): void; + bindAttribute(attribute: IHeroModifierOwner | null): void; /** * 对指定属性值进行修改 @@ -67,6 +75,13 @@ export interface IModifierStateSave { readonly state: unknown; } +export interface IHeroAttributeSave { + /** 勇士的基础属性值,即未经过任何 Buff 或装备加成的属性 */ + readonly values: THero; + /** 保存启用的属性修饰器状态 */ + readonly modifiers: readonly IModifierStateSave[]; +} + export interface IHeroAttributeCloneOption { /** 是否克隆属性修饰器,默认为 `true` */ cloneModifier: boolean; @@ -148,7 +163,10 @@ export interface IReadonlyHeroAttribute { getModifierSaveEnabled(modifier: IHeroModifier): boolean; } -export interface IHeroAttribute extends IReadonlyHeroAttribute { +export interface IHeroAttribute + extends + IReadonlyHeroAttribute, + ISaveableContent> { /** * 设置勇士的基础属性 * @param name 属性名称 @@ -218,6 +236,32 @@ export interface IHeroAttribute extends IReadonlyHeroAttribute { catchCalculateProgress( name: K ): Iterable<[IHeroModifier, THero[K]]>; + + /** + * 注册一个修饰器工厂函数 + * @param type 修饰器类型 + * @param cons 工厂函数 + */ + registerModifier( + type: string, + cons: () => IHeroModifier + ): void; + + /** + * 创建指定类型的修饰器实例 + * @param type 修饰器类型 + */ + createModifier(type: string): IHeroModifier | null; + + /** + * 创建指定类型的修饰器实例并插入至本属性,默认进入存档 + * @param type 修饰器类型 + * @param name 属性名称 + */ + createAndInsertModifier( + type: string, + name: K + ): IHeroModifier | null; } //#endregion