template/packages-user/data-state/test/saveablesRoundTrip.test.ts

471 lines
16 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 测试 CoreState 顶层存读档:经公开 saveState/loadState 对 5 个 saveable 做整体往返
import { describe, expect, it, vi } from 'vitest';
import { logger } from '@motajs/common';
import {
type IEnemyAttr,
type IHeroAttr,
type IItemRawData,
FaceDirection,
ItemCategory,
SaveCompression,
TileType
} from '@user/data-common';
import {
Enemy,
ValueModifier,
type IGameMap,
type IHeroStateSave,
type IMapLayer,
type IMapStoreSave
} from '@user/data-base';
import { CoreState, createCoreState } from '../src/core';
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;
};
});
/** CoreState 已注册的 5 个可存档 id */
const SAVEABLE_IDS = [
'@system/hero',
'@system/flags',
'@system/maps',
'@system/enemy',
'@system/replay'
] as const;
/** 存读档测试覆盖的三档压缩级别 */
const COMPRESSIONS = [
SaveCompression.NoCompression,
SaveCompression.LowCompression,
SaveCompression.HighCompression
] as const;
/** 构造一个带合成属性的怪物模板对象 */
function createEnemy(hp = 20): Enemy<IEnemyAttr> {
return new Enemy<IEnemyAttr>('closure-enemy', 1, {
hp,
atk: 8,
def: 5,
money: 2,
exp: 3,
point: 1,
guard: new Set()
});
}
interface SeededState {
map: IGameMap;
layer: IMapLayer;
}
interface ExpectedReplayStep {
command: number;
params: readonly (number | string | boolean)[];
}
/** 顶层录像写入的 10 步多样化命令序列,命令与参数类型均不全相同 */
const REPLAY_STEPS: readonly ExpectedReplayStep[] = [
{ command: 0, params: [] },
{ command: 1, params: [] },
{ command: 2, params: [] },
{ command: 3, params: [] },
{ command: 4, params: [2, 3] },
{ command: 5, params: [12] },
{ command: 6, params: [3, 0, true] },
{ command: 7, params: [0] },
{ command: 1, params: [] },
{ command: 5, params: ['potion'] }
];
/** 向顶层录像写入 10 步混合命令,覆盖移动/瞬移/用道具/装备/卸下 */
function seedReplay(state: CoreState): void {
state.replaySystem.record(0);
state.replaySystem.record(1);
state.replaySystem.record(2);
state.replaySystem.record(3);
state.replaySystem.record(4, 2, 3);
state.replaySystem.record(5, 12);
state.replaySystem.record(6, 3, 0, true);
state.replaySystem.record(7, 0);
state.replaySystem.record(1);
state.replaySystem.record(5, 'potion');
}
/** 为 5 个 saveable 写入已知的全部关键状态 */
function seedState(state: CoreState): SeededState {
state.hero.registerModifier('@system/value', () => new ValueModifier(5));
state.hero.createAndInsertModifier('@system/value', 'atk');
const attribute = state.hero.getModifiableAttribute();
attribute.set('hp', 88);
attribute.set('atk', 10);
attribute.set('def', 6);
attribute.set('money', 20);
attribute.set('exp', 30);
state.hero.location.setPos(3, 4);
state.hero.location.setFloor('F1');
state.hero.location.mover.setFaceDir(FaceDirection.Up);
state.flags.setFieldValue('score', 7);
state.flags.setFieldValue('coins', 12);
state.flags.addFieldValue('stage', 2);
const map = state.maps.createMap('F1', 2, 2);
const layer = map.addLayer();
layer.setZIndex(0);
map.setActiveStatus(true);
state.maps.compareWith(
new Map([['F1', new Map([[0, new Uint32Array(4)]])]])
);
layer.setBlock(5, 0, 0);
layer.setBlock(7, 1, 1);
state.enemyManager.addPrefab(createEnemy());
state.enemyManager.compareWith(new Map([[1, createEnemy()]]));
state.enemyManager.modifyPrefabAttribute(1, prefab => {
prefab.setAttribute('hp', 30);
prefab.setAttribute('atk', 9);
return prefab;
});
seedReplay(state);
return { map, layer };
}
/** 保存后修改每个 saveable 的全部关键状态,用于验证读档恢复 */
function mutateState(state: CoreState, seeded: SeededState): void {
const attribute = state.hero.getModifiableAttribute();
attribute.set('hp', 1);
attribute.set('atk', 1);
attribute.set('def', 0);
attribute.set('money', 0);
attribute.set('exp', 0);
state.hero.location.setPos(9, 9);
state.hero.location.setFloor('F9');
state.hero.location.mover.setFaceDir(FaceDirection.Down);
state.flags.setFieldValue('score', 0);
state.flags.setFieldValue('coins', 0);
state.flags.setFieldValue('stage', 0);
seeded.layer.setBlock(0, 0, 0);
seeded.layer.setBlock(0, 1, 1);
seeded.map.setActiveStatus(false);
state.enemyManager.modifyPrefabAttribute(1, prefab => {
prefab.setAttribute('hp', 99);
prefab.setAttribute('atk', 1);
return prefab;
});
state.replaySystem.record(9, 1);
}
/** 向顶层状态注册同引用集成用例使用的 atk+5 装备道具定义 */
function registerIdentityEquipment(state: CoreState): void {
state.tileStore.addTile({
num: 9001,
id: 'identity-sword',
events: {},
type: TileType.Item,
pass: { onlyEvents: false, inPass: 15, outPass: 15 },
eventPass: true
});
const value: [SelectKey<IHeroAttr, number>, number][] = [['atk', 5]];
const item: IItemRawData<IHeroAttr> = {
num: 9001,
id: 'identity-sword',
category: ItemCategory.Equipment,
name: 'identity-sword',
text: 'identity-sword',
hideInToolbox: false,
effect: { useEvent: null, useEffect: () => {}, canUse: () => false },
equip: {
slots: [0],
animate: 'sword',
value: new Map(value),
percentage: new Map(),
loadEvent: null,
unloadEvent: null
}
};
state.itemStore.addItem(item);
}
/** 逐 saveable 严格断言全部关键字段回到存档点 */
function assertRestored(state: CoreState, seeded: SeededState): void {
const attribute = state.hero.getModifiableAttribute();
expect(attribute.getBaseAttribute('hp')).toBe(88);
expect(attribute.getBaseAttribute('atk')).toBe(10);
expect(attribute.getBaseAttribute('def')).toBe(6);
expect(attribute.getBaseAttribute('money')).toBe(20);
expect(attribute.getBaseAttribute('exp')).toBe(30);
expect(attribute.getFinalAttribute('atk')).toBe(15);
expect(state.hero.getLocation()).toEqual({
x: 3,
y: 4,
direction: FaceDirection.Up
});
expect(state.hero.location.floorId).toBe('F1');
expect(state.flags.getFieldValue<number>('score')).toBe(7);
expect(state.flags.getFieldValue<number>('coins')).toBe(12);
expect(state.flags.getFieldValue<number>('stage')).toBe(2);
expect(state.maps.isMapActive('F1')).toBe(true);
expect(seeded.layer.getBlock(0, 0)).toBe(5);
expect(seeded.layer.getBlock(1, 1)).toBe(7);
expect(state.enemyManager.getPrefab(1)!.getAttribute('hp')).toBe(30);
expect(state.enemyManager.getPrefab(1)!.getAttribute('atk')).toBe(9);
const route = state.replaySystem.array;
expect(route.length).toBe(REPLAY_STEPS.length);
REPLAY_STEPS.forEach((step, index) => {
expect(route.get(index)).toEqual({
command: step.command,
params: step.params,
index
});
});
}
describe('CoreState top-level save and load round trips', () => {
// 验证 5 个已注册 saveable 在三个压缩档下均经公开入口整体往返
it('round-trips every registered saveable across all compressions', () => {
for (const compression of COMPRESSIONS) {
const state = createCoreState();
const seeded = seedState(state);
const snapshot = state.saveState(compression);
expect([...snapshot.keys()].sort()).toEqual(
[...SAVEABLE_IDS].sort()
);
mutateState(state, seeded);
state.loadState(snapshot, compression);
assertRestored(state, seeded);
}
});
// 验证快照以 5 个 id 为键且与活对象分离
it('keeps a snapshot separated from the live saveables', () => {
const state = createCoreState();
const seeded = seedState(state);
const snapshot = state.saveState(SaveCompression.NoCompression);
expect([...snapshot.keys()].sort()).toEqual([...SAVEABLE_IDS].sort());
state.hero.getModifiableAttribute().set('hp', 1);
seeded.layer.setBlock(0, 0, 0);
const heroSave = snapshot.get(
'@system/hero'
) as IHeroStateSave<IHeroAttr>;
const mapSave = snapshot.get('@system/maps') as IMapStoreSave;
expect(heroSave.attribute.values.hp).toBe(88);
expect(mapSave.floors.get('F1')!.layers.get(0)!.fullMap![0]).toBe(5);
});
// 验证 getSaveableContent 对 5 个 id 返回同一实例,未知 id 返回 null
it('resolves the registered saveables by id through getSaveableContent', () => {
const state = createCoreState();
for (const id of SAVEABLE_IDS) {
expect(state.getSaveableContent(id)).not.toBeNull();
}
expect(state.getSaveableContent('@system/hero')).toBe(state.hero);
expect(state.getSaveableContent('@system/flags')).toBe(state.flags);
expect(state.getSaveableContent('@system/maps')).toBe(state.maps);
expect(state.getSaveableContent('@system/enemy')).toBe(
state.enemyManager
);
expect(state.getSaveableContent('@system/replay')).toBe(
state.replaySystem
);
expect(state.getSaveableContent('@missing')).toBeNull();
});
});
describe('CoreState container same-reference load (#06-17-4/5/6)', () => {
// 验证经顶层 CoreState 三档往返后装备实例、flag 字段与 follower 实例均为同一实例,
// 且其数值与位置回到存档点
it('keeps container instances across save and load in every compression', () => {
for (const compression of COMPRESSIONS) {
const state = createCoreState();
registerIdentityEquipment(state);
const uid = state.hero.items.equipment.add(9001);
state.hero.equip.setSlots(['weapon']);
state.hero.equip.equip(uid, 0);
const equipmentBefore = state.hero.items.equipment.get(uid)!;
const fieldBefore = state.flags.getOrInsert('identity', 7);
state.hero.followers.addFollower(9102);
const followerBefore = state.hero.followers.getFollower(0)!;
const snapshot = state.saveState(compression);
// 装备数值只能经读档改变flag 与 follower 位置则在存档后继续改动
equipmentBefore.loadState(
{
uid,
num: 9001,
value: new Map<SelectKey<IHeroAttr, number>, number>([
['atk', 9]
]),
percentage: new Map<SelectKey<IHeroAttr, number>, number>()
},
SaveCompression.NoCompression
);
state.flags.setFieldValue('identity', 99);
followerBefore.location.setPos(9, 9);
state.loadState(snapshot, compression);
expect(state.hero.items.equipment.get(uid)).toBe(equipmentBefore);
expect([...equipmentBefore.getModifiers()][0][1].getValue()).toBe(
5
);
expect(state.flags.getField('identity')).toBe(fieldBefore);
expect(fieldBefore.get()).toBe(7);
expect(state.hero.followers.getFollower(0)).toBe(followerBefore);
expect(followerBefore.location.x).toBe(0);
expect(followerBefore.location.y).toBe(0);
}
});
});
describe('CoreState save and load guards', () => {
// 验证重复注册同一 saveable id 时经 logger.catch 观测到警告码 112
it('warns code 112 when adding a duplicate saveable id', () => {
const state = createCoreState();
const result = logger.catch(() =>
state.addSaveableContent('@system/hero', state.hero)
);
expect(result.info.map(info => info.code)).toContain(112);
expect(state.getSaveableContent('@system/hero')).toBe(state.hero);
});
// 验证为未添加的对象绑定执行器时经 logger.catch 观测到警告码 113
it('warns code 113 when binding an executor to an unadded saveable', () => {
const state = createCoreState();
const fake = {
saveState: (_compression: SaveCompression) => null,
loadState: (_state: unknown, _compression: SaveCompression) => {}
};
const result = logger.catch(() =>
state.bindSaveableExecuter(fake as never, { afterLoad: () => {} })
);
expect(result.info.map(info => info.code)).toContain(113);
});
// 验证存档缺失某个 saveable key 时经 logger.catch 观测到警告码 177
it('warns code 177 when the save data misses a saveable key', () => {
const state = createCoreState();
const snapshot = new Map(
state.saveState(SaveCompression.NoCompression)
);
snapshot.delete('@system/flags');
const result = logger.catch(() =>
state.loadState(snapshot, SaveCompression.NoCompression)
);
expect(result.info.map(info => info.code)).toContain(177);
});
// 验证存档缺失 saveable key 时只触发警告码 177不再误报 178177/178 语义互斥)
it('warns code 177 but not 178 when the save data misses a saveable key', () => {
const state = createCoreState();
const snapshot = new Map(
state.saveState(SaveCompression.NoCompression)
);
snapshot.delete('@system/flags');
const result = logger.catch(() =>
state.loadState(snapshot, SaveCompression.NoCompression)
);
const codes = result.info.map(info => info.code);
expect(codes).toContain(177);
expect(codes).not.toContain(178);
});
// 验证存档含未注册未加载key 时经 logger.catch 观测到警告码 178#06-09-5
it('warns code 178 when the save data has keys that are not loaded', () => {
const state = createCoreState();
const snapshot = new Map(
state.saveState(SaveCompression.NoCompression)
);
snapshot.set('@system/extra', null);
const result = logger.catch(() =>
state.loadState(snapshot, SaveCompression.NoCompression)
);
expect(result.info.map(info => info.code)).toContain(178);
});
});
describe('CoreState container coverage for compression-less saveables', () => {
// 验证不接受压缩参数的 flags 与 replay 经容器三档往返均恢复到存档点
it('restores flags and replay through the container across all compressions', () => {
for (const compression of COMPRESSIONS) {
const state = createCoreState();
state.flags.setFieldValue('score', 5);
state.flags.addFieldValue('stage', 3);
state.replaySystem.record(1);
state.replaySystem.record(2, 7);
const snapshot = state.saveState(compression);
state.flags.setFieldValue('score', 0);
state.flags.setFieldValue('stage', 0);
state.replaySystem.record(9, 1);
state.loadState(snapshot, compression);
expect(state.flags.getFieldValue<number>('score')).toBe(5);
expect(state.flags.getFieldValue<number>('stage')).toBe(3);
expect(state.replaySystem.array.length).toBe(2);
expect(state.replaySystem.array.get(0)).toEqual({
command: 1,
params: [],
index: 0
});
expect(state.replaySystem.array.get(1)).toEqual({
command: 2,
params: [7],
index: 1
});
}
});
});