fix(07-11): #06-17-4 preserve equipment instances across loadState

- HeroEquipsStore.loadState 按 uid 复用现有 EquipmentState 原地读档
- 同 uid 但装备图块数字不同时才替换该实例
- 存档中不存在的装备实例一律删除(以存档为准)
- IHeroEquipsStore.loadState jsDoc 补写同引用与删除语义
- 新增同实例保真、删除多余实例、num 不同替换三条回归用例
This commit is contained in:
unanmed 2026-09-17 13:47:20 +08:00
parent 94ceb50cfe
commit 153f4a490a
4 changed files with 177 additions and 6 deletions

View File

@ -6,12 +6,17 @@ import {
type IItemRawData,
ItemCategory,
ItemStore,
SaveCompression,
TileStore,
TileType
} from '@user/data-common';
import { HeroEquipsStore } from './equipStore';
import { PercentageModifier, ValueModifier } from './modifier';
import { type IEquipmentSortHandler, type IEquipmentSorter } from './types';
import {
type IEquipmentSortHandler,
type IEquipmentSorter,
type IEquipmentStateSave
} from './types';
vi.hoisted(() => {
vi.stubGlobal('main', { replayChecking: true });
@ -45,6 +50,13 @@ afterAll(() => {
type HeroKey = SelectKey<IHeroAttr, number>;
/** 存读档测试覆盖的三档压缩级别 */
const SAVE_COMPRESSIONS = [
SaveCompression.NoCompression,
SaveCompression.LowCompression,
SaveCompression.HighCompression
] as const;
interface TestEnv {
state: IDataCommon;
tileStore: IDataCommon['tileStore'];
@ -217,6 +229,82 @@ describe('HeroEquipsStore instances', () => {
});
});
/** 构造一份仅指定数值加成的无压缩装备存档,用于把活实例改到指定存档点 */
function createValueSave(
uid: number,
atk: number
): IEquipmentStateSave<IHeroAttr> {
return {
uid,
num: 10,
value: new Map<HeroKey, number>([['atk', atk]]),
percentage: new Map<HeroKey, number>()
};
}
describe('HeroEquipsStore same-reference load (#06-17-4)', () => {
// 验证同 uid 装备实例在三档压缩读档前后为同一实例,且数值恢复到存档点
it('keeps the same instance and restores values across all compressions', () => {
for (const compression of SAVE_COMPRESSIONS) {
const env = createEnv();
registerItem(env, createItem(10, 'sword', [['atk', 5]]));
const uid = env.store.add(10);
const before = env.store.get(uid)!;
// 装备数值只能经读档改变,先用一份自定义存档把活实例改到 9 作为存档点
before.loadState(
createValueSave(uid, 9),
SaveCompression.NoCompression
);
const saved = env.store.saveState(compression);
before.loadState(
createValueSave(uid, 1),
SaveCompression.NoCompression
);
env.store.loadState(saved, compression);
expect(env.store.get(uid)).toBe(before);
expect([...before.getModifiers()][0][1].getValue()).toBe(9);
}
});
// 验证存档中不存在的装备实例在读档后被删除(以存档为准)
it('deletes instances absent from the save', () => {
const env = createEnv();
registerItem(env, createItem(10, 'sword'));
const kept = env.store.add(10);
const before = env.store.get(kept)!;
const saved = env.store.saveState(SaveCompression.NoCompression);
const extra = env.store.add(10);
env.store.loadState(saved, SaveCompression.NoCompression);
expect(env.store.get(kept)).toBe(before);
expect(env.store.get(extra)).toBeNull();
expect(env.store.count(10)).toBe(1);
});
// 验证同 uid 但装备图块数字不同的存档无法复用实例,改为替换该实例
it('replaces the instance when the saved item number differs', () => {
const env = createEnv();
registerItem(env, createItem(10, 'sword', [['atk', 5]]));
registerItem(env, createItem(11, 'axe', [['atk', 3]]));
const uid = env.store.add(10);
const before = env.store.get(uid)!;
const saved = env.store.saveState(SaveCompression.NoCompression);
const swapped = {
equipments: saved.equipments.map(v => ({ ...v, num: 11 }))
};
env.store.loadState(swapped, SaveCompression.NoCompression);
expect(env.store.get(uid)).not.toBe(before);
expect(env.store.get(uid)?.item.num).toBe(11);
});
});
describe('EquipmentState modifiers', () => {
// 验证装备实例按定义生成数值与百分比修饰器
it('builds value and percentage modifiers from the item', () => {

View File

@ -268,16 +268,34 @@ export class HeroEquipsStore<THero> implements IHeroEquipsStore<THero> {
state: IHeroEquipsStoreSave<THero>,
compression: SaveCompression
): void {
this.instanceMap.clear();
// 按 uid 复用现有实例原地读档,使外部持有的引用跨读档仍然有效;
// 仅当 uid 相同但装备图块数字不同时才替换实例
const savedUids = new Set<number>();
for (const save of state.equipments) {
const raw = this.state.itemStore.getData(save.num);
if (!raw) {
logger.error(59, save.num.toString());
continue;
}
const instance = new EquipmentState<THero>(save.uid, raw, false);
instance.loadState(save, compression);
this.instanceMap.set(save.uid, instance);
savedUids.add(save.uid);
const existing = this.instanceMap.get(save.uid);
if (existing && existing.item.num === save.num) {
existing.loadState(save, compression);
} else {
const instance = new EquipmentState<THero>(
save.uid,
raw,
false
);
instance.loadState(save, compression);
this.instanceMap.set(save.uid, instance);
}
}
// 以存档为准:存档中不存在的装备实例一律删除
for (const uid of this.instanceMap.keys()) {
if (!savedUids.has(uid)) {
this.instanceMap.delete(uid);
}
}
const maxUid = maxBy(state.equipments, 'uid');
if (!maxUid) {

View File

@ -690,3 +690,54 @@ describe('HeroState same-reference attribute load (#06-17-1)', () => {
}
});
});
describe('HeroState same-reference equipment load (#06-17-4)', () => {
// 验证经勇士容器三档往返后装备实例为同一实例,且数值恢复到存档点
it('keeps the equipment instance and restores values across all compressions', () => {
for (const compression of SAVE_COMPRESSIONS) {
const hero = createEquipHero();
const uid = hero.items.equipment.add(10);
const before = hero.items.equipment.get(uid)!;
// 装备数值只能经读档改变,先用一份自定义存档把活实例改到 9 作为存档点
before.loadState(
{
uid,
num: 10,
value: new Map<HeroKey, number>([['atk', 9]]),
percentage: new Map<HeroKey, number>()
},
SaveCompression.NoCompression
);
const saved = hero.saveState(compression);
before.loadState(
{
uid,
num: 10,
value: new Map<HeroKey, number>([['atk', 1]]),
percentage: new Map<HeroKey, number>()
},
SaveCompression.NoCompression
);
hero.loadState(saved, compression);
expect(hero.items.equipment.get(uid)).toBe(before);
expect([...before.getModifiers()][0][1].getValue()).toBe(9);
}
});
// 验证存档中不存在的装备实例在读档后被删除(以存档为准)
it('deletes equipment instances absent from the save', () => {
const hero = createEquipHero();
const kept = hero.items.equipment.add(10);
const before = hero.items.equipment.get(kept)!;
const saved = hero.saveState(SaveCompression.NoCompression);
const extra = hero.items.equipment.add(10);
hero.loadState(saved, SaveCompression.NoCompression);
expect(hero.items.equipment.get(kept)).toBe(before);
expect(hero.items.equipment.get(extra)).toBeNull();
});
});

View File

@ -7,7 +7,8 @@ import {
IItemRawData,
IObjectMovable,
IObjectMover,
ISaveableContent
ISaveableContent,
SaveCompression
} from '@user/data-common';
import { IPassPredicate } from '../map';
@ -710,6 +711,19 @@ export interface IHeroEquipsStore<THero>
* @param item id
*/
count(item: number | string): number;
/**
* uid
* {@link get} / {@link instancesOf} / {@link instances}
* uid
*
* @param state
* @param compression
*/
loadState(
state: IHeroEquipsStoreSave<THero>,
compression: SaveCompression
): void;
}
export interface IHeroEquipmentSave {