mirror of
https://github.com/motajs/template.git
synced 2026-09-25 12:00:16 +08:00
- HeroEquipsStore.loadState 按 uid 复用现有 EquipmentState 原地读档 - 同 uid 但装备图块数字不同时才替换该实例 - 存档中不存在的装备实例一律删除(以存档为准) - IHeroEquipsStore.loadState jsDoc 补写同引用与删除语义 - 新增同实例保真、删除多余实例、num 不同替换三条回归用例
744 lines
27 KiB
TypeScript
744 lines
27 KiB
TypeScript
// 测试勇士吁E<E59081><45>系统存读档<E8AFBB>E<EFBFBD>同实例往返、压缩档与码E58/59
|
||
import { afterAll, describe, expect, it, vi } from 'vitest';
|
||
import {
|
||
type IDataCommon,
|
||
type IHeroAttr,
|
||
type IItemRawData,
|
||
Dir8FaceHandler,
|
||
FaceDirection,
|
||
ItemCategory,
|
||
ItemStore,
|
||
SaveCompression,
|
||
TileStore,
|
||
TileType
|
||
} from '@user/data-common';
|
||
import { logger } from '@motajs/common';
|
||
import { HeroAttribute } from './attribute';
|
||
import { PercentageModifier, ValueModifier } from './modifier';
|
||
import { HeroLocation } from './location';
|
||
import { HeroRendering } from './rendering';
|
||
import { HeroState } from './state';
|
||
import { HeroEquipment } from './equipment';
|
||
import { EquipmentState, HeroEquipsStore } from './equipStore';
|
||
import { HeroItems } from './items';
|
||
import { HeroFollowersController } from './follower';
|
||
import { type IHeroState } from './types';
|
||
|
||
vi.hoisted(() => {
|
||
vi.stubGlobal('main', { replayChecking: true });
|
||
vi.stubGlobal('location', { origin: 'http://localhost' });
|
||
Map.prototype.getOrInsert ??= function <K, V>(
|
||
this: Map<K, V>,
|
||
key: K,
|
||
value: V
|
||
): V {
|
||
const existing = this.get(key);
|
||
if (existing !== undefined) return existing;
|
||
this.set(key, value);
|
||
return value;
|
||
};
|
||
Map.prototype.getOrInsertComputed ??= function <K, V>(
|
||
this: Map<K, V>,
|
||
key: K,
|
||
callback: (key: K) => V
|
||
): V {
|
||
const existing = this.get(key);
|
||
if (existing !== undefined) return existing;
|
||
const value = callback(key);
|
||
this.set(key, value);
|
||
return value;
|
||
};
|
||
});
|
||
|
||
afterAll(() => {
|
||
vi.unstubAllGlobals();
|
||
});
|
||
|
||
/** 存读档测试要E<E8A681><45>皁E<E79A81><45>档压缩级别 */
|
||
const SAVE_COMPRESSIONS = [
|
||
SaveCompression.NoCompression,
|
||
SaveCompression.LowCompression,
|
||
SaveCompression.HighCompression
|
||
] as const;
|
||
|
||
type HeroKey = SelectKey<IHeroAttr, number>;
|
||
|
||
interface EquipEnv {
|
||
state: IDataCommon;
|
||
tileStore: IDataCommon['tileStore'];
|
||
itemStore: IDataCommon['itemStore'];
|
||
store: HeroEquipsStore<IHeroAttr>;
|
||
equipment: HeroEquipment<IHeroAttr>;
|
||
}
|
||
|
||
/** 极E<E69E81><45>一个含图块、E<E38081><45>具存储与假录像系统的公共层假对象 */
|
||
function createState(): IDataCommon {
|
||
const tileStore = new TileStore();
|
||
const itemStore = new ItemStore<IHeroAttr, unknown>();
|
||
const route = { add: vi.fn() };
|
||
return {
|
||
tileStore,
|
||
itemStore,
|
||
replaySystem: { route, disable: vi.fn(), revert: vi.fn() }
|
||
} as never;
|
||
}
|
||
|
||
/** 极E<E69E81><45>一份合<E4BBBD>E皁E<E79A81><45>士基础属性 */
|
||
function createBaseAttr(): IHeroAttr {
|
||
return {
|
||
name: 'hero',
|
||
hp: 100,
|
||
hpmax: 100,
|
||
atk: 10,
|
||
def: 5,
|
||
mdef: 0,
|
||
mana: 0,
|
||
manamax: 0,
|
||
money: 0,
|
||
exp: 0
|
||
};
|
||
}
|
||
|
||
/** 极E<E69E81><45>一个带合<E5B8A6>E裁E<E8A381><45>E<EFBFBD><45>性皁E<E79A81><45>具定乁E*/
|
||
function createEquipItem(
|
||
num: number,
|
||
id: string,
|
||
slots: (number | string)[] = [0],
|
||
value: [HeroKey, number][] = [],
|
||
percentage: [HeroKey, number][] = []
|
||
): IItemRawData<IHeroAttr> {
|
||
return {
|
||
num,
|
||
id,
|
||
category: ItemCategory.Equipment,
|
||
name: id,
|
||
text: id,
|
||
hideInToolbox: false,
|
||
effect: { useEvent: null, useEffect: () => {}, canUse: () => false },
|
||
equip: {
|
||
slots,
|
||
animate: 'sword',
|
||
value: new Map(value),
|
||
percentage: new Map(percentage),
|
||
loadEvent: null,
|
||
unloadEvent: null
|
||
}
|
||
};
|
||
}
|
||
|
||
/** 极E<E69E81><45>一个非裁E<E8A381><45>E<EFBFBD><45>道<EFBFBD>E定义,用于要E<E8A681><45>背包刁E<E58881><45>存读档 */
|
||
function createPlainItem(
|
||
num: number,
|
||
id: string,
|
||
category: ItemCategory
|
||
): IItemRawData<IHeroAttr> {
|
||
return {
|
||
num,
|
||
id,
|
||
category,
|
||
name: id,
|
||
text: id,
|
||
hideInToolbox: false,
|
||
effect: { useEvent: null, useEffect: () => {}, canUse: () => false },
|
||
equip: {
|
||
slots: [0],
|
||
animate: 'sword',
|
||
value: new Map(),
|
||
percentage: new Map(),
|
||
loadEvent: null,
|
||
unloadEvent: null
|
||
}
|
||
};
|
||
}
|
||
|
||
/** 向图块与道具存储注册一个道<E4B8AA>E定乁E*/
|
||
function registerItem(env: EquipEnv, item: IItemRawData<IHeroAttr>): void {
|
||
env.tileStore.addTile({
|
||
num: item.num,
|
||
id: item.id,
|
||
events: {},
|
||
type: TileType.Item,
|
||
pass: { onlyEvents: false, inPass: 15, outPass: 15 },
|
||
eventPass: true
|
||
});
|
||
env.itemStore.addItem(item);
|
||
}
|
||
|
||
/** 极E<E69E81><45>一个裁E<E8A381>E裁E<E8A381><45>E<EFBFBD><45>例存储与勇士裁E<E8A381><45>E<EFBFBD><45>象皁E<E79A81><45>试环墁E*/
|
||
function createEquipEnv(): EquipEnv {
|
||
const state = createState();
|
||
const store = new HeroEquipsStore<IHeroAttr>(state);
|
||
const equipment = new HeroEquipment<IHeroAttr>(
|
||
store,
|
||
new HeroAttribute<IHeroAttr>(createBaseAttr())
|
||
);
|
||
return {
|
||
state,
|
||
tileStore: state.tileStore,
|
||
itemStore: state.itemStore,
|
||
store,
|
||
equipment
|
||
};
|
||
}
|
||
|
||
/** 极E<E69E81><45>一个注册亁E<E4BA81><45>随老E<E88081><45>块的公共层假对象 */
|
||
function createFollowerState(): IDataCommon {
|
||
const state = createState();
|
||
state.tileStore.addTile({
|
||
num: 100,
|
||
id: 'ghost',
|
||
events: {},
|
||
type: TileType.Npc,
|
||
pass: { onlyEvents: false, inPass: 15, outPass: 15 },
|
||
eventPass: true
|
||
});
|
||
return state;
|
||
}
|
||
|
||
/** 极E<E69E81><45>一个裁E<E8A381>E完<45>E皁E<E79A81><45>士状态对象 */
|
||
function createHeroState(): IHeroState<IHeroAttr> {
|
||
return new HeroState<IHeroAttr>(
|
||
createState(),
|
||
new Dir8FaceHandler(),
|
||
new HeroAttribute<IHeroAttr>(createBaseAttr())
|
||
);
|
||
}
|
||
|
||
/** 构造一个已注册装备定义并设置好装备槽的勇士状态对象(基础 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();
|
||
const item = createEquipItem(10, 'sword', [0], [], [['hp', 0.5]]);
|
||
registerItem(env, item);
|
||
const state = new EquipmentState<IHeroAttr>(0, item);
|
||
const modifier = [...state.getModifiers()][0][1];
|
||
modifier.setValue(0.9);
|
||
|
||
const saved = state.saveState(compression);
|
||
state.loadState(saved, compression);
|
||
|
||
const restored = [...state.getModifiers()].find(
|
||
([, current]) => current instanceof PercentageModifier
|
||
);
|
||
return restored?.[1].getValue();
|
||
}
|
||
|
||
describe('Hero modifier save and load round trips', () => {
|
||
// 验证EValueModifier 皁E<E79A81><45>值在三个压缩档下均能同实例恢夁E
|
||
it('restores a value modifier across all compressions', () => {
|
||
for (const compression of SAVE_COMPRESSIONS) {
|
||
const modifier = new ValueModifier(5);
|
||
|
||
const saved = modifier.saveState(compression);
|
||
modifier.setValue(9);
|
||
modifier.loadState(saved, compression);
|
||
|
||
expect(modifier.getValue()).toBe(5);
|
||
}
|
||
});
|
||
|
||
// 验证EPercentageModifier 皁E<E79A81><45>值在三个压缩档下均能同实例恢夁E
|
||
it('restores a percentage modifier across all compressions', () => {
|
||
for (const compression of SAVE_COMPRESSIONS) {
|
||
const modifier = new PercentageModifier(0.25);
|
||
|
||
const saved = modifier.saveState(compression);
|
||
modifier.setValue(0.9);
|
||
modifier.loadState(saved, compression);
|
||
|
||
expect(modifier.getValue()).toBe(0.25);
|
||
}
|
||
});
|
||
});
|
||
|
||
describe('HeroLocation save and load round trips', () => {
|
||
// 验证坐栁E<E6A081><45>楼层与朝向在同实例上完整恢夁E
|
||
it('restores position, floor and direction on the same instance', () => {
|
||
const location = new HeroLocation(
|
||
createState(),
|
||
{ x: 3, y: 4, direction: FaceDirection.Right },
|
||
new Dir8FaceHandler()
|
||
);
|
||
location.setPos(1, 2);
|
||
location.setFloor('F2');
|
||
location.mover.setFaceDir(FaceDirection.Up);
|
||
|
||
const saved = location.saveState();
|
||
location.setPos(9, 9);
|
||
location.setFloor('F9');
|
||
location.mover.setFaceDir(FaceDirection.Down);
|
||
location.loadState(saved);
|
||
|
||
expect(location.x).toBe(1);
|
||
expect(location.y).toBe(2);
|
||
expect(location.floorId).toBe('F2');
|
||
expect(location.getCurrentFaceDirection()).toBe(FaceDirection.Up);
|
||
});
|
||
});
|
||
|
||
describe('HeroRendering save and load round trips', () => {
|
||
// 验证不透<E4B88D>E度在同实例上恢夁E
|
||
it('restores alpha on the same instance', () => {
|
||
const rendering = new HeroRendering(createState());
|
||
rendering.setAlpha(0.25);
|
||
|
||
const saved = rendering.saveState();
|
||
rendering.setAlpha(1);
|
||
rendering.loadState(saved);
|
||
|
||
expect(rendering.alpha).toBe(0.25);
|
||
});
|
||
});
|
||
|
||
describe('HeroEquipment save and load round trips', () => {
|
||
// 验证裁E<E8A381><45>E<EFBFBD><45>与已裁E<E8A381><45>E<EFBFBD><45>封E<E5B081><45>同实例上恢复(存盘经结构化<E69E84>E隁E<E99A81><45>拟真实序<E5AE9E>E化!E
|
||
it('restores slots and equipped mapping on the same instance', () => {
|
||
const env = createEquipEnv();
|
||
registerItem(env, createEquipItem(10, 'sword', [0], [['atk', 5]]));
|
||
env.equipment.setSlots(['weapon']);
|
||
const uid = env.store.add(10);
|
||
env.equipment.equip(uid, 0);
|
||
|
||
const saved = structuredClone(env.equipment.saveState());
|
||
env.equipment.unequip(0);
|
||
expect(env.equipment.getEquipped(0)).toBeUndefined();
|
||
|
||
env.equipment.loadState(saved);
|
||
|
||
expect(env.equipment.getEquipped(0)).toBe(uid);
|
||
expect(env.equipment.slots).toEqual(['weapon']);
|
||
});
|
||
|
||
// 验证 saveState 返回与活对象解耦的深拷贝快照(#06-09-2)
|
||
it('returns an equipment snapshot independent from the live state', () => {
|
||
const env = createEquipEnv();
|
||
registerItem(env, createEquipItem(10, 'sword', [0], [['atk', 5]]));
|
||
env.equipment.setSlots(['weapon']);
|
||
const uid = env.store.add(10);
|
||
env.equipment.equip(uid, 0);
|
||
|
||
const saved = env.equipment.saveState();
|
||
env.equipment.unequip(0);
|
||
env.equipment.loadState(saved);
|
||
|
||
expect(env.equipment.getEquipped(0)).toBe(uid);
|
||
expect(env.equipment.slots).toEqual(['weapon']);
|
||
});
|
||
});
|
||
|
||
describe('EquipmentState save and load round trips', () => {
|
||
// 验证百刁E<E58881><45>加成在无压缩档下同实例恢复到存档点
|
||
it('restores a percentage modifier on the same instance in NoCompression', () => {
|
||
expect(roundTripPercentageModifier(SaveCompression.NoCompression)).toBe(
|
||
0.5
|
||
);
|
||
});
|
||
|
||
// 验证 Low 压缩档读档以装备原始定义为回退基准,未修改的百分比加成不丢失(#06-09-1)
|
||
it('restores a percentage modifier on the same instance in LowCompression', () => {
|
||
expect(
|
||
roundTripPercentageModifier(SaveCompression.LowCompression)
|
||
).toBe(0.5);
|
||
});
|
||
|
||
// 验证 High 压缩档读档以装备原始定义为回退基准,未修改的百分比加成不丢失(#06-09-1)
|
||
it('restores a percentage modifier on the same instance in HighCompression', () => {
|
||
expect(
|
||
roundTripPercentageModifier(SaveCompression.HighCompression)
|
||
).toBe(0.5);
|
||
});
|
||
|
||
// 验证 NoCompression 读档按 value/percentage 分表恢复数值修饰器(#06-09-1)
|
||
it('restores a value modifier on the same instance', () => {
|
||
const env = createEquipEnv();
|
||
const item = createEquipItem(10, 'sword', [0], [['atk', 5]]);
|
||
registerItem(env, item);
|
||
const state = new EquipmentState<IHeroAttr>(0, item);
|
||
const modifier = [...state.getModifiers()][0][1];
|
||
modifier.setValue(99);
|
||
|
||
const saved = state.saveState(SaveCompression.NoCompression);
|
||
state.loadState(saved, SaveCompression.NoCompression);
|
||
|
||
const restored = [...state.getModifiers()].find(
|
||
([name]) => name === 'atk'
|
||
);
|
||
expect(restored?.[1].getValue()).toBe(5);
|
||
});
|
||
|
||
// 验证压缩档读档回退原始定义后未修改的数值修饰器仍保留(#06-09-1)
|
||
it('keeps unchanged value modifiers for compressed snapshots', () => {
|
||
const env = createEquipEnv();
|
||
const item = createEquipItem(10, 'sword', [0], [['atk', 5]]);
|
||
registerItem(env, item);
|
||
const state = new EquipmentState<IHeroAttr>(0, item);
|
||
|
||
const saved = state.saveState(SaveCompression.LowCompression);
|
||
state.loadState(saved, SaveCompression.LowCompression);
|
||
|
||
expect([...state.getModifiers()].map(([name]) => name)).toEqual([
|
||
'atk'
|
||
]);
|
||
});
|
||
});
|
||
|
||
describe('HeroEquipsStore save and load round trips', () => {
|
||
// 验证裁E<E8A381><45>E<EFBFBD><45>例在三个压缩档下同实例恢复并续接自墁Euid
|
||
it('restores equipment instances and the uid counter across all compressions', () => {
|
||
for (const compression of SAVE_COMPRESSIONS) {
|
||
const env = createEquipEnv();
|
||
registerItem(env, createEquipItem(10, 'sword', [0], [['atk', 5]]));
|
||
const first = env.store.add(10);
|
||
const second = env.store.add(10);
|
||
|
||
const saved = env.store.saveState(compression);
|
||
env.store.delete(first);
|
||
env.store.loadState(saved, compression);
|
||
|
||
expect(env.store.get(first)?.uid).toBe(first);
|
||
expect(env.store.get(second)?.uid).toBe(second);
|
||
expect(env.store.add(10)).toBe(second + 1);
|
||
}
|
||
});
|
||
|
||
// 验证存档缺失道<E5A4B1>E原始数据时绁Elogger.catch 观测到错误码E59
|
||
it('warns code 59 when the item raw data is missing', () => {
|
||
const env = createEquipEnv();
|
||
const saved = {
|
||
equipments: [
|
||
{
|
||
uid: 0,
|
||
num: 999,
|
||
value: new Map<HeroKey, number>(),
|
||
percentage: new Map<HeroKey, number>()
|
||
}
|
||
]
|
||
};
|
||
|
||
const result = logger.catch(() =>
|
||
env.store.loadState(saved, SaveCompression.NoCompression)
|
||
);
|
||
|
||
expect(result.info.map(info => info.code)).toContain(59);
|
||
});
|
||
|
||
// 验证存档不含任何裁E<E8A381><45>E<EFBFBD><45>例时绁Elogger.catch 观测到错误码E58
|
||
it('warns code 58 when the max equipment uid cannot be found', () => {
|
||
const env = createEquipEnv();
|
||
|
||
const result = logger.catch(() =>
|
||
env.store.loadState(
|
||
{ equipments: [] },
|
||
SaveCompression.NoCompression
|
||
)
|
||
);
|
||
|
||
expect(result.info.map(info => info.code)).toContain(58);
|
||
});
|
||
});
|
||
|
||
describe('HeroItems save and load round trips', () => {
|
||
// 验证永乁E<E4B981><45>消耗道具刁E<E58881><45>在三个压缩档下同实例恢夁E
|
||
it('restores constant and consumable item tables across all compressions', () => {
|
||
for (const compression of SAVE_COMPRESSIONS) {
|
||
const env = createEquipEnv();
|
||
registerItem(
|
||
env,
|
||
createPlainItem(20, 'key', ItemCategory.Constant)
|
||
);
|
||
registerItem(
|
||
env,
|
||
createPlainItem(21, 'potion', ItemCategory.Consumable)
|
||
);
|
||
const items = new HeroItems<IHeroAttr>(env.state);
|
||
items.addItem(20, 3);
|
||
items.addItem(21, 2);
|
||
|
||
const saved = items.saveState(compression);
|
||
items.addItem(20, -3);
|
||
items.addItem(21, -2);
|
||
items.loadState(saved, compression);
|
||
|
||
expect(items.itemCount(20)).toBe(3);
|
||
expect(items.itemCount(21)).toBe(2);
|
||
expect(items.getItemState(20)?.id).toBe('key');
|
||
}
|
||
});
|
||
});
|
||
|
||
describe('HeroFollower save and load round trips', () => {
|
||
// 验证跟随老E<E88081><45>置、楼层与渲染在同实例上恢夁E
|
||
it('restores follower location and rendering on the same instance', () => {
|
||
const state = createFollowerState();
|
||
const faceHandler = new Dir8FaceHandler();
|
||
const heroLocation = new HeroLocation(
|
||
state,
|
||
{ x: 0, y: 0, direction: FaceDirection.Down },
|
||
faceHandler
|
||
);
|
||
const controller = new HeroFollowersController(
|
||
state,
|
||
heroLocation,
|
||
faceHandler
|
||
);
|
||
const follower = controller.addFollower(100);
|
||
follower.location.setPos(2, 3);
|
||
follower.location.setFloor('F1');
|
||
follower.location.mover.setFaceDir(FaceDirection.Up);
|
||
follower.rendering.setAlpha(0.5);
|
||
|
||
for (const compression of SAVE_COMPRESSIONS) {
|
||
const saved = follower.saveState(compression);
|
||
follower.location.setPos(9, 9);
|
||
follower.location.setFloor('F9');
|
||
follower.location.mover.setFaceDir(FaceDirection.Down);
|
||
follower.rendering.setAlpha(1);
|
||
follower.loadState(saved, compression);
|
||
|
||
expect(follower.location.x).toBe(2);
|
||
expect(follower.location.y).toBe(3);
|
||
expect(follower.location.floorId).toBe('F1');
|
||
expect(follower.location.getCurrentFaceDirection()).toBe(
|
||
FaceDirection.Up
|
||
);
|
||
expect(follower.rendering.alpha).toBe(0.5);
|
||
}
|
||
});
|
||
});
|
||
|
||
describe('HeroState save and load round trips', () => {
|
||
// 验证属性、修饰器与位置在三个压缩档下同实例恢复并重建属性对象
|
||
it('restores attributes, modifiers and location across all compressions', () => {
|
||
for (const compression of SAVE_COMPRESSIONS) {
|
||
const hero = createHeroState();
|
||
hero.registerModifier('@system/value', () => new ValueModifier(5));
|
||
hero.createAndInsertModifier('@system/value', 'atk');
|
||
hero.getModifiableAttribute().set('hp', 88);
|
||
hero.location.setPos(1, 1);
|
||
hero.location.setFloor('F1');
|
||
hero.location.mover.setFaceDir(FaceDirection.Up);
|
||
|
||
const saved = hero.saveState(compression);
|
||
hero.getModifiableAttribute().set('hp', 1);
|
||
hero.location.setPos(9, 9);
|
||
hero.loadState(saved, compression);
|
||
|
||
expect(hero.getModifiableAttribute().getBaseAttribute('hp')).toBe(
|
||
88
|
||
);
|
||
expect(hero.getModifiableAttribute().getFinalAttribute('atk')).toBe(
|
||
15
|
||
);
|
||
expect(hero.getLocation()).toEqual({
|
||
x: 1,
|
||
y: 1,
|
||
direction: FaceDirection.Up
|
||
});
|
||
expect(hero.location.floorId).toBe('F1');
|
||
}
|
||
});
|
||
|
||
// 验证存盘禁用<E7A681>E<EFBFBD>Eave=false<73>E<EFBFBD>的修饰器不进<E4B88D>E存档且读档后不<E5908E>E存在
|
||
it('excludes save-disabled modifiers from the snapshot', () => {
|
||
const hero = createHeroState();
|
||
hero.registerModifier('@system/value', () => new ValueModifier(5));
|
||
hero.createAndInsertModifier('@system/value', 'atk');
|
||
const disabled = hero.createAndInsertModifier('@system/value', 'atk')!;
|
||
hero.getModifiableAttribute().setModifierSaveEnabled(disabled, false);
|
||
|
||
const saved = hero.saveState(SaveCompression.NoCompression);
|
||
expect(saved.attribute.modifiers).toHaveLength(1);
|
||
|
||
hero.loadState(saved, SaveCompression.NoCompression);
|
||
|
||
const values = [
|
||
...hero.getModifiableAttribute().getModifiers('atk')
|
||
].map(modifier => modifier.getValue());
|
||
expect(values).toEqual([5]);
|
||
expect(hero.getModifiableAttribute().getFinalAttribute('atk')).toBe(15);
|
||
});
|
||
});
|
||
|
||
describe('HeroState container save and load coverage for sub systems', () => {
|
||
// 验证不接受压缩参数皁E<E79A81><45>位与渲染经勇士容器三档往返均恢复到存档点
|
||
it('restores location and rendering through the container across all compressions', () => {
|
||
for (const compression of SAVE_COMPRESSIONS) {
|
||
const hero = createHeroState();
|
||
hero.location.setPos(2, 3);
|
||
hero.location.setFloor('F2');
|
||
hero.location.mover.setFaceDir(FaceDirection.Right);
|
||
hero.rendering.setAlpha(0.25);
|
||
|
||
const saved = hero.saveState(compression);
|
||
hero.location.setPos(9, 9);
|
||
hero.location.setFloor('F9');
|
||
hero.location.mover.setFaceDir(FaceDirection.Down);
|
||
hero.rendering.setAlpha(1);
|
||
hero.loadState(saved, compression);
|
||
|
||
expect(hero.location.x).toBe(2);
|
||
expect(hero.location.y).toBe(3);
|
||
expect(hero.location.floorId).toBe('F2');
|
||
expect(hero.location.getCurrentFaceDirection()).toBe(
|
||
FaceDirection.Right
|
||
);
|
||
expect(hero.rendering.alpha).toBe(0.25);
|
||
}
|
||
});
|
||
|
||
// 验证经容器三档往返均恢复已装备映射与槽位(#06-09-2)
|
||
it('restores the equipped mapping through the container across all compressions', () => {
|
||
for (const compression of SAVE_COMPRESSIONS) {
|
||
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']);
|
||
const uid = hero.items.equipment.add(10);
|
||
hero.equip.equip(uid, 0);
|
||
|
||
const saved = hero.saveState(compression);
|
||
hero.equip.unequip(0);
|
||
hero.loadState(saved, compression);
|
||
|
||
expect(hero.equip.getEquipped(0)).toBe(uid);
|
||
expect(hero.equip.slots).toEqual(['weapon']);
|
||
}
|
||
});
|
||
});
|
||
|
||
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);
|
||
}
|
||
});
|
||
});
|
||
|
||
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();
|
||
});
|
||
});
|