mirror of
https://github.com/motajs/template.git
synced 2026-10-21 06:40:16 +08:00
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:
parent
94f6a70e38
commit
6a60476531
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user