feat(07-09): make HeroAttribute own its save state in place

- add IHeroAttributeSave<THero> and have IHeroAttribute extend ISaveableContent
- move the modifier factory registry into HeroAttribute with registerModifier/createModifier/createAndInsertModifier
- implement in-place saveState/loadState (values deep copy, save-enabled modifier enumeration, registry-driven rebuild)
- carry registry entries into clone() output
- introduce IHeroModifierOwner so modifier owner typing stays assignable for generic attributes
- add attribute.test.ts same-reference save/load regressions across all compressions
This commit is contained in:
unanmed 2026-09-16 18:37:54 +08:00
parent 94f6a70e38
commit 6a60476531
3 changed files with 248 additions and 7 deletions

View File

@ -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<TestHero> {
});
}
interface TestNumericHero {
hp: number;
atk: number;
}
/** 构造一个仅含数值属性的勇士属性对象,使注册表工厂的具体修饰器可满足泛型签名 */
function createNumericAttribute(): HeroAttribute<TestNumericHero> {
return new HeroAttribute<TestNumericHero>({ 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);
});
});

View File

@ -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<T, V> implements IHeroModifier<T, V, V> {
abstract readonly type: string;
abstract readonly priority: number;
owner: IHeroAttribute<unknown> | null = null;
owner: IHeroModifierOwner | null = null;
constructor(private currentValue: V) {}
@ -28,7 +31,7 @@ export abstract class BaseHeroModifier<T, V> implements IHeroModifier<T, V, V> {
return this.currentValue;
}
bindAttribute(attribute: IHeroAttribute<unknown> | null): void {
bindAttribute(attribute: IHeroModifierOwner | null): void {
this.owner = attribute;
}
@ -52,6 +55,11 @@ export class HeroAttribute<THero> implements IHeroAttribute<THero> {
private readonly modifierName: Map<IHeroModifier, keyof THero> = new Map();
/** 当前标记为不存档的修饰器集合 */
private readonly modifierNosave: Set<IHeroModifier> = new Set();
/** 修饰器工厂函数注册表 */
private readonly registry: Map<
string,
<K extends keyof THero>() => IHeroModifier<THero[K]>
> = new Map();
/** 当前勇士最终属性 */
private readonly finalAttribute: THero;
@ -250,6 +258,32 @@ export class HeroAttribute<THero> implements IHeroAttribute<THero> {
return !this.modifierNosave.has(modifier);
}
registerModifier(
type: string,
cons: <K extends keyof THero>() => IHeroModifier<THero[K]>
): void {
this.registry.set(type, cons);
}
createModifier<T, V>(type: string): IHeroModifier<T, V> | null {
const cons = this.registry.get(type);
if (!cons) {
logger.warn(116, type);
return null;
}
return cons() as IHeroModifier<T, V>;
}
createAndInsertModifier<K extends keyof THero, V>(
type: string,
name: K
): IHeroModifier<THero[K], V> | null {
const modifier = this.createModifier<THero[K], V>(type);
if (!modifier) return null;
this.addModifier(name, modifier);
return modifier;
}
//#endregion
//#region 属性克隆
@ -261,6 +295,10 @@ export class HeroAttribute<THero> implements IHeroAttribute<THero> {
const cloned = new HeroAttribute<THero>(
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<THero> implements IHeroAttribute<THero> {
}
//#endregion
//#region 属性存读档
saveState(compression: SaveCompression): IHeroAttributeSave<THero> {
const modifiers: IModifierStateSave<THero>[] = [];
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<THero>,
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
}

View File

@ -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<unknown> | null;
readonly owner: IHeroModifierOwner | null;
/**
* 设置修饰器参数值
@ -42,7 +50,7 @@ export interface IHeroModifier<
* 绑定勇士属性对象
* @param attribute 勇士属性对象
*/
bindAttribute(attribute: IHeroAttribute<unknown> | null): void;
bindAttribute(attribute: IHeroModifierOwner | null): void;
/**
* 对指定属性值进行修改
@ -67,6 +75,13 @@ export interface IModifierStateSave<THero> {
readonly state: unknown;
}
export interface IHeroAttributeSave<THero> {
/** 勇士的基础属性值,即未经过任何 Buff 或装备加成的属性 */
readonly values: THero;
/** 保存启用的属性修饰器状态 */
readonly modifiers: readonly IModifierStateSave<THero>[];
}
export interface IHeroAttributeCloneOption {
/** 是否克隆属性修饰器,默认为 `true` */
cloneModifier: boolean;
@ -148,7 +163,10 @@ export interface IReadonlyHeroAttribute<THero> {
getModifierSaveEnabled(modifier: IHeroModifier): boolean;
}
export interface IHeroAttribute<THero> extends IReadonlyHeroAttribute<THero> {
export interface IHeroAttribute<THero>
extends
IReadonlyHeroAttribute<THero>,
ISaveableContent<IHeroAttributeSave<THero>> {
/**
* 设置勇士的基础属性
* @param name 属性名称
@ -218,6 +236,32 @@ export interface IHeroAttribute<THero> extends IReadonlyHeroAttribute<THero> {
catchCalculateProgress<K extends keyof THero>(
name: K
): Iterable<[IHeroModifier<THero[K]>, THero[K]]>;
/**
* 注册一个修饰器工厂函数
* @param type 修饰器类型
* @param cons 工厂函数
*/
registerModifier(
type: string,
cons: <K extends keyof THero>() => IHeroModifier<THero[K]>
): void;
/**
* 创建指定类型的修饰器实例
* @param type 修饰器类型
*/
createModifier<T, V>(type: string): IHeroModifier<T, V> | null;
/**
* 创建指定类型的修饰器实例并插入至本属性,默认进入存档
* @param type 修饰器类型
* @param name 属性名称
*/
createAndInsertModifier<K extends keyof THero, V>(
type: string,
name: K
): IHeroModifier<THero[K], V> | null;
}
//#endregion