mirror of
https://github.com/motajs/template.git
synced 2026-09-25 12:00:16 +08:00
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:
parent
6a60476531
commit
1f0af69004
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
|
||||
/**
|
||||
* 获取可修改勇士对象
|
||||
*/
|
||||
|
||||
Loading…
Reference in New Issue
Block a user