Files
template/packages-user/data-state/__test__/dataClosure.test.ts
T
AncTe ff9fd77880 test(08-09): align data-state integration and perf tests to shipped shapes
- dataClosure: local CoreState helper, Record equip.value, ReplayCommandResult/Active commands, natural sandbox step

- enemyCombination: local CoreState helper

- perf: CoreState loadStarter assembly, Record equip.value, addLayer('event')
2026-10-07 19:13:15 +08:00

367 lines
12 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest';
import { WebLoadStarter } from '@motajs/loader';
import {
IEnemyAttr,
IReplaySandbox,
IHeroAttr,
IItemRawData,
ItemCategory,
ReplaySystem,
ReplayCommandResult,
ReplayCommandType,
SaveCompression,
ReplayCode,
TileType
} from '@user/data-common';
import { IEnemy, IReadonlyHeroAttribute } from '@user/data-base';
import { IReadonlyEnemyHandler } from '@user/data-system';
import { CoreState } from '../src/core';
import { MainDamageCalculator } from '../src/enemy/calculator';
import { createClosedLoopFixture } from './fixtures/closed-loop';
/** 按 08-08 约定的 CoreState 装配创建一个独立状态实例 */
function createCoreState(): CoreState {
return new CoreState({
loadStarter: new WebLoadStarter(),
coreURL: 'placeholder'
});
}
/** 供测试读取的稳定指令码顺序 */
export const REPLAY_COMMAND_ORDER: readonly ReplayCode[] = [
ReplayCode.Up,
ReplayCode.Right,
ReplayCode.Down,
ReplayCode.Left,
ReplayCode.Teleport,
ReplayCode.UseItem,
ReplayCode.Equip,
ReplayCode.Unequip
];
vi.hoisted(() => {
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;
};
});
function createEnemy(): IEnemy<IEnemyAttr> {
let attrs: IEnemyAttr = {
hp: 20,
atk: 8,
def: 5,
money: 2,
exp: 3,
point: 1,
guard: new Set()
};
const enemy: IEnemy<IEnemyAttr> = {
id: 'closure-enemy',
code: 1,
getSpecial: () => null,
hasSpecial: () => false,
iterateSpecials: () => [],
getAttribute: key => attrs[key],
cloneAttributes: () => structuredClone(attrs),
clone: () => {
const copy = createEnemy();
copy.copyFrom(enemy);
return copy;
},
addSpecial: () => {},
deleteSpecial: () => {},
setAttribute: (key, value) => {
attrs[key] = value;
},
addAttribute: (key, value) => {
attrs[key] = (attrs[key] as number) + value;
},
copyFrom: other => {
attrs = other.cloneAttributes();
},
saveState: () => ({
attrs: structuredClone(attrs),
specials: new Map()
}),
loadState: state => {
attrs = structuredClone(state.attrs);
}
};
return enemy;
}
function addEnemy(state: CoreState): IEnemy<IEnemyAttr> {
state.enemyManager.addPrefab(createEnemy());
const enemy = state.enemyManager.createEnemy(1);
if (!enemy) throw new Error('closure enemy was not created');
return enemy;
}
async function waitForEnded(sandbox: IReplaySandbox): Promise<void> {
for (let index = 0; index < 1000 && !sandbox.ended; index++) {
await new Promise<void>(resolve => setTimeout(resolve, 0));
}
if (!sandbox.ended) throw new Error('closure replay did not end');
}
/** 向顶层状态注册一件 atk+5 的装备并装备至 0 号槽,返回装备实例 uid */
function equipAtkItem(state: CoreState): number {
state.tileStore.addTile({
num: 9001,
id: 'closure-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: 'closure-sword',
category: ItemCategory.Equipment,
name: 'closure-sword',
text: 'closure-sword',
hideInToolbox: false,
effect: { useEvent: null, useEffect: () => {}, canUse: () => false },
equip: {
slots: [0],
animate: 'sword',
value: Object.fromEntries(value),
percentage: {},
loadEvent: null,
unloadEvent: null
}
};
state.itemStore.addItem(item);
state.hero.equip.setSlots(['weapon']);
const uid = state.hero.items.equipment.add(9001);
state.hero.equip.equip(uid, 0);
return uid;
}
/** 存读档测试覆盖的三档压缩级别 */
const SAVE_COMPRESSIONS = [
SaveCompression.NoCompression,
SaveCompression.LowCompression,
SaveCompression.HighCompression
] as const;
describe('DATA-01 closure', () => {
// 验证敌人管理器能够创建敌人、修改属性并完成独立存档恢复
it('creates, mutates, and saves an enemy through the public manager', () => {
const state = createCoreState();
const enemy = addEnemy(state);
enemy.setAttribute('hp', 31);
const saved = enemy.saveState(SaveCompression.NoCompression);
enemy.setAttribute('hp', 2);
enemy.loadState(saved, SaveCompression.NoCompression);
expect(enemy.getAttribute('hp')).toBe(31);
expect(enemy.getAttribute('atk')).toBe(8);
expect(enemy.id).toBe('closure-enemy');
});
// 验证 Flag 的设置读取和 save/load round trip 保留字段值
it('round-trips Flag values through save and load', () => {
const state = createCoreState();
state.flags.setFieldValue('closureScore', 7);
state.flags.addFieldValue('closureScore', 5);
const saved = state.flags.saveState(SaveCompression.NoCompression);
state.flags.setFieldValue('closureScore', 0);
state.flags.loadState(saved, SaveCompression.NoCompression);
expect(state.flags.getFieldValue<number>('closureScore')).toBe(12);
expect(state.flags.occupied('closureScore')).toBe(true);
});
// 验证同一敌人和勇士属性下的战斗伤害结果保持确定
it('computes deterministic combat damage', () => {
const state = createCoreState();
const enemy = addEnemy(state);
const hero = {
getBaseAttribute: (
name: keyof IHeroAttr
): IHeroAttr[typeof name] => (name === 'hp' ? 100 : 0),
getFinalAttribute: (
name: keyof IHeroAttr
): IHeroAttr[typeof name] => {
if (name === 'atk') return 20;
if (name === 'def') return 5;
return 0;
}
} as IReadonlyHeroAttribute<IHeroAttr>;
const handler = {
enemy,
context: state.enemyContext,
locator: { x: 0, y: 0 },
hero,
state
} as IReadonlyEnemyHandler<IEnemyAttr, IHeroAttr>;
const calculator = new MainDamageCalculator();
const first = calculator.calculate(handler);
const second = calculator.calculate(handler);
expect(first).not.toBeNull();
expect(second).toEqual(first);
expect(first?.damage).toBe(3);
expect(first?.turn).toBe(2);
});
// 验证可存档勇士内容在修改后能够恢复保存时的属性快照
it('round-trips a saveable hero state', () => {
const state = createCoreState();
const hero = state.hero.getModifiableAttribute();
hero.set('hp', 88);
const saved = state.hero.saveState(SaveCompression.NoCompression);
hero.set('hp', 1);
state.hero.loadState(saved, SaveCompression.NoCompression);
expect(state.hero.attribute.getBaseAttribute('hp')).toBe(88);
});
// 验证 trigger/event executor 等待事件完成并应用事件层状态变化
it('executes a trigger event and applies its awaited mutation', async () => {
const fixture = createClosedLoopFixture();
expect(fixture.map.eventLayer?.getBlock(1, 0)).toBe(1);
fixture.sandbox.play();
await waitForEnded(fixture.sandbox);
expect(fixture.sandbox.ended).toBe(true);
expect(fixture.eventCompleted()).toBe(true);
expect(fixture.map.eventLayer?.getBlock(1, 0)).toBe(2);
expect(fixture.state.eventStore.getEvent('mutate-map')).not.toBeNull();
});
// 验证 replay 注册顺序、参数路线、异步完成与首个失败停止行为
it('preserves replay order, awaits completion, and stops on failure', async () => {
const state = createCoreState();
expect(
REPLAY_COMMAND_ORDER.every(code =>
state.replaySystem.getCommand(code)
)
).toBe(true);
expect(REPLAY_COMMAND_ORDER).toEqual([
ReplayCode.Up,
ReplayCode.Right,
ReplayCode.Down,
ReplayCode.Left,
ReplayCode.Teleport,
ReplayCode.UseItem,
ReplayCode.Equip,
ReplayCode.Unequip
]);
state.replaySystem.record(ReplayCode.Right, 4, 'route');
expect(state.replaySystem.array.get(0)).toMatchObject({
command: ReplayCode.Right,
params: [4, 'route']
});
const fixture = createClosedLoopFixture();
fixture.sandbox.play();
await waitForEnded(fixture.sandbox);
expect(fixture.eventCompleted()).toBe(true);
const replay = new ReplaySystem();
let laterExecuted = false;
replay.registerCommand(0, {
type: ReplayCommandType.Active,
execute: async () => ReplayCommandResult.Failed
});
replay.registerCommand(1, {
type: ReplayCommandType.Active,
execute: async () => {
laterExecuted = true;
return ReplayCommandResult.Success;
}
});
replay.record(0);
replay.record(1);
const sandbox = replay.createReplaySandbox({
route: replay.array,
reseter: { reset: () => {} }
});
await expect(sandbox.step()).resolves.toBe(false);
expect(sandbox.getReplayed()).toBe(2);
expect(laterExecuted).toBe(false);
});
});
describe('CoreState hero attribute same-reference load (#06-17-2)', () => {
// 验证读档前战斗侧绑定与活属性为同一实例且反映装备加成
it('binds the combat context to the live hero attribute before load', () => {
const state = createCoreState();
equipAtkItem(state);
const bound = state.enemyContext.getBindedHero();
const attribute = state.hero.getModifiableAttribute();
expect(bound).toBe(attribute);
expect(attribute.getFinalAttribute('atk')).toBe(5);
});
// 验证读档后战斗侧无需重绑即读到同一活属性(#06-17-2)
it('reads the live attribute after load without rebinding', () => {
const state = createCoreState();
const uid = equipAtkItem(state);
const saved = state.hero.saveState(SaveCompression.NoCompression);
state.hero.equip.unequip(0);
state.hero.getModifiableAttribute().set('atk', 1);
state.hero.loadState(saved, SaveCompression.NoCompression);
expect(state.enemyContext.getBindedHero()).toBe(
state.hero.getModifiableAttribute()
);
expect(
state.enemyContext.getBindedHero()!.getFinalAttribute('atk')
).toBe(5);
expect(state.hero.equip.getEquipped(0)).toBe(uid);
});
// 验证三档压缩下战斗侧始终读到同一活属性并反映读档值
it('reads the same live attribute across all compressions', () => {
for (const compression of SAVE_COMPRESSIONS) {
const state = createCoreState();
const uid = equipAtkItem(state);
const saved = state.hero.saveState(compression);
state.hero.equip.unequip(0);
state.hero.getModifiableAttribute().set('atk', 1);
state.hero.loadState(saved, compression);
expect(state.enemyContext.getBindedHero()).toBe(
state.hero.getModifiableAttribute()
);
expect(
state.enemyContext.getBindedHero()!.getFinalAttribute('atk')
).toBe(5);
expect(state.hero.equip.getEquipped(0)).toBe(uid);
}
});
});