fix(07-09): #06-17-1 load hero attribute in place and keep equipment bonuses

- IHeroStateSave.attribute becomes IHeroAttributeSave<THero>; top-level modifiers removed
- HeroState.attribute is readonly and loadState delegates to attribute.loadState (never reassigns)
- delete attachAttribute from IHeroState and HeroState, and its only test case
- thin-delegate registerModifier/createModifier/createAndInsertModifier to the attribute registry
- loadEquipEffect mounts equipment modifiers with save=false so equipment bonuses only return via re-equip
- add #06-17-1 regressions: same attribute instance, bonuses kept, no double-count when type is registered
This commit is contained in:
unanmed 2026-09-16 18:43:33 +08:00
parent 6a60476531
commit 1f0af69004
5 changed files with 95 additions and 73 deletions

View File

@ -78,9 +78,10 @@ export class HeroEquipment<THero> implements IHeroEquipment<THero> {
* @param state 装备状态实例
*/
private loadEquipEffect(state: IEquipmentState<THero>) {
// 装备修饰器由 HeroEquipment.loadState 的重新装备恢复,故不进入属性存档,避免与属性读档的重建重复计入
for (const [name, modifier] of state.getModifiers()) {
// @ts-expect-error 泛型无法推导
this.attribute.addModifier(name, modifier, true);
this.attribute.addModifier(name, modifier, false);
}
}

View File

@ -204,6 +204,19 @@ function createHeroState(): IHeroState<IHeroAttr> {
);
}
/** 构造一个已注册装备定义并设置好装备槽的勇士状态对象(基础 atk 为 10) */
function createEquipHero(): IHeroState<IHeroAttr> {
const env = createEquipEnv();
registerItem(env, createEquipItem(10, 'sword', [0], [['atk', 5]]));
const hero = new HeroState<IHeroAttr>(
env.state,
new Dir8FaceHandler(),
new HeroAttribute<IHeroAttr>(createBaseAttr())
);
hero.equip.setSlots(['weapon']);
return hero;
}
/** 在持E<E68C81><45>压缩档下对裁E<E8A381><45>E<EFBFBD><45>刁E<E58881><45>加成做一次同实例往返,返回读档后的修饰器值 */
function roundTripPercentageModifier(compression: SaveCompression): unknown {
const env = createEquipEnv();
@ -546,7 +559,7 @@ describe('HeroState save and load round trips', () => {
hero.getModifiableAttribute().setModifierSaveEnabled(disabled, false);
const saved = hero.saveState(SaveCompression.NoCompression);
expect(saved.modifiers).toHaveLength(1);
expect(saved.attribute.modifiers).toHaveLength(1);
hero.loadState(saved, SaveCompression.NoCompression);
@ -608,3 +621,72 @@ describe('HeroState container save and load coverage for sub systems', () => {
}
});
});
describe('HeroState same-reference attribute load (#06-17-1)', () => {
// 验证三档压缩读档后装备加成仍在且属性对象为同一实例
it('keeps equipment bonuses and the attribute instance across all compressions', () => {
for (const compression of SAVE_COMPRESSIONS) {
const hero = createEquipHero();
const uid = hero.items.equipment.add(10);
hero.equip.equip(uid, 0);
const attrBefore = hero.getModifiableAttribute();
expect(attrBefore.getFinalAttribute('atk')).toBe(15);
const saved = hero.saveState(compression);
hero.equip.unequip(0);
hero.getModifiableAttribute().set('atk', 1);
hero.loadState(saved, compression);
expect(hero.getModifiableAttribute()).toBe(attrBefore);
expect(attrBefore.getFinalAttribute('atk')).toBe(15);
expect(attrBefore.getBaseAttribute('atk')).toBe(10);
expect(hero.equip.getEquipped(0)).toBe(uid);
}
});
// 验证读档后装备增删仍作用于活属性实例
it('applies equipment changes to the live attribute after load', () => {
const hero = createEquipHero();
const uid = hero.items.equipment.add(10);
hero.equip.equip(uid, 0);
const attrBefore = hero.getModifiableAttribute();
const saved = hero.saveState(SaveCompression.NoCompression);
hero.equip.unequip(0);
hero.loadState(saved, SaveCompression.NoCompression);
expect(attrBefore.getFinalAttribute('atk')).toBe(15);
hero.equip.unequip(0);
expect(attrBefore.getFinalAttribute('atk')).toBe(10);
hero.equip.equip(uid, 0);
expect(attrBefore.getFinalAttribute('atk')).toBe(15);
});
// 验证修饰器类型命中注册表时装备加成不翻倍(英雄 5 + 装备 5 → 20 而非 25)
it('does not double-count equipment bonuses when the type is registered', () => {
for (const compression of SAVE_COMPRESSIONS) {
const hero = createEquipHero();
hero.registerModifier('@system/value', () => new ValueModifier(5));
hero.createAndInsertModifier('@system/value', 'atk');
const uid = hero.items.equipment.add(10);
hero.equip.equip(uid, 0);
expect(hero.getModifiableAttribute().getFinalAttribute('atk')).toBe(
20
);
const saved = hero.saveState(compression);
hero.equip.unequip(0);
hero.getModifiableAttribute().set('atk', 1);
hero.loadState(saved, compression);
expect(hero.getModifiableAttribute().getFinalAttribute('atk')).toBe(
20
);
expect([
...hero.getModifiableAttribute().getModifiers('atk')
]).toHaveLength(2);
}
});
});

View File

@ -114,17 +114,6 @@ describe('HeroState assembly', () => {
isolated.set('hp', 1);
expect(attribute.getBaseAttribute('hp')).toBe(100);
});
// 验证 attachAttribute 替换绑定的属性对象
it('replaces the bound attribute through attachAttribute', () => {
const hero = createHeroState();
const replacement = new HeroAttribute<IHeroAttr>(createBaseAttr());
hero.attachAttribute(replacement);
expect(hero.getModifiableAttribute()).toBe(replacement);
expect(hero.getAttribute()).toBe(replacement);
});
});
describe('HeroState modifier registry', () => {

View File

@ -1,4 +1,3 @@
import { HeroAttribute } from './attribute';
import { HeroEquipment } from './equipment';
import { HeroFollowersController } from './follower';
import { HeroItems } from './items';
@ -14,7 +13,6 @@ import {
IHeroRendering,
IHeroState,
IHeroStateSave,
IModifierStateSave,
IReadonlyHeroAttribute,
IHeroChangeFloorInfo,
IHeroStateHooks
@ -26,23 +24,12 @@ import {
IFacedTileLocator,
SaveCompression
} from '@user/data-common';
import {
Hookable,
HookController,
IHookController,
logger
} from '@motajs/common';
import { Hookable, HookController, IHookController } from '@motajs/common';
export class HeroState<THero>
extends Hookable<IHeroStateHooks>
implements IHeroState<THero>
{
/** 修饰器工厂函数注册表 */
private readonly registry: Map<
string,
<K extends keyof THero>() => IHeroModifier<THero[K]>
> = new Map();
readonly location: IHeroLocation;
readonly rendering: IHeroRendering;
readonly followers: IHeroFollowersController;
@ -52,7 +39,7 @@ export class HeroState<THero>
constructor(
state: IDataCommon,
faceHandler: IFaceHandler<FaceDirection>,
public attribute: IHeroAttribute<THero>
public readonly attribute: IHeroAttribute<THero>
) {
super();
this.rendering = new HeroRendering(state);
@ -87,10 +74,6 @@ export class HeroState<THero>
//#region 属性相关
attachAttribute(attribute: IHeroAttribute<THero>): void {
this.attribute = attribute;
}
getModifiableAttribute(): IHeroAttribute<THero> {
return this.attribute;
}
@ -107,26 +90,18 @@ export class HeroState<THero>
type: string,
cons: <K extends keyof THero>() => IHeroModifier<THero[K]>
): void {
this.registry.set(type, cons);
this.attribute.registerModifier(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>;
return this.attribute.createModifier<T, V>(type);
}
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.attribute.addModifier(name, modifier);
return modifier;
return this.attribute.createAndInsertModifier<K, V>(type, name);
}
//#endregion
@ -146,25 +121,15 @@ export class HeroState<THero>
}
saveState(compression: SaveCompression): IHeroStateSave<THero> {
const modifiers: IModifierStateSave<THero>[] = [];
for (const [name, modifier] of this.attribute.iterateModifiers()) {
if (!this.attribute.getModifierSaveEnabled(modifier)) continue;
modifiers.push({
name: name as keyof THero,
type: modifier.type,
state: modifier.saveState(compression)
});
}
const followerSaves = this.followers
.getAllFollowers()
.map(v => v.saveState(compression));
return {
attribute: this.attribute.toStructured(),
attribute: this.attribute.saveState(compression),
location: this.location.saveState(compression),
rendering: this.rendering.saveState(compression),
followers: followerSaves,
modifiers,
items: this.items.saveState(compression),
equip: this.equip.saveState(compression)
};
@ -174,15 +139,8 @@ export class HeroState<THero>
state: IHeroStateSave<THero>,
compression: SaveCompression
): void {
const newAttribute = new HeroAttribute<THero>(state.attribute);
for (const save of state.modifiers) {
const cons = this.registry.get(save.type);
if (!cons) continue;
const modifier = cons();
modifier.loadState(save.state, compression);
newAttribute.addModifier(save.name, modifier);
}
this.attribute = newAttribute;
// 属性原地读档:不替换实例,使装备与战斗侧持有的引用跨读档始终有效
this.attribute.loadState(state.attribute, compression);
this.location.loadState(state.location, compression);
this.rendering.loadState(state.rendering, compression);
this.items.loadState(state.items, compression);

View File

@ -828,16 +828,14 @@ export interface IHeroStateHooks extends IHookBase {
}
export interface IHeroStateSave<THero> {
/** 勇士属性状态 */
readonly attribute: THero;
/** 勇士属性状态,含基础属性值与保存启用的修饰器 */
readonly attribute: IHeroAttributeSave<THero>;
/** 勇士当前位置 */
readonly location: IHeroLocationSave;
/** 勇士渲染状态 */
readonly rendering: IHeroRenderingSave;
/** 勇士当前的跟随者 */
readonly followers: readonly IHeroFollowerSave[];
/** 勇士属性修饰器状态 */
readonly modifiers: readonly IModifierStateSave<THero>[];
/** 勇士道具背包状态 */
readonly items: IHeroItemsSave<THero>;
/** 勇士装备状态 */
@ -866,12 +864,6 @@ export interface IHeroState<THero>
*/
getLocation(): IFacedTileLocator;
/**
* 绑定勇士属性对象
* @param attribute 勇士属性对象
*/
attachAttribute(attribute: IHeroAttribute<THero>): void;
/**
* 获取可修改勇士对象
*/