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

359 lines
12 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.

import { describe, expect, it, vi } from 'vitest';
import {
IEnemyAttr,
IReplaySandbox,
IHeroAttr,
IItemRawData,
ItemCategory,
ReplaySystem,
SaveCompression,
ReplayCode,
TileType
} from '@user/data-common';
import { IEnemy, IReadonlyHeroAttribute } from '@user/data-base';
import { IReadonlyEnemyHandler } from '@user/data-system';
import { createCoreState, CoreState } from '../src/core';
import { MainDamageCalculator } from '../src/enemy/calculator';
import { createClosedLoopFixture } from './fixtures/closed-loop';
/** 供测试读取的稳定指令码顺序 */
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;
};
});
interface IManualReplaySandbox extends IReplaySandbox {
pausing: boolean;
playing: boolean;
}
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: new Map(value),
percentage: new Map(),
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, { execute: async () => false });
replay.registerCommand(1, {
execute: async () => {
laterExecuted = true;
return true;
}
});
replay.record(0);
replay.record(1);
const sandbox = replay.createReplaySandbox({
route: replay.array,
reseter: { reset: () => {} }
}) as IManualReplaySandbox;
sandbox.pausing = false;
sandbox.playing = true;
await expect(sandbox.step()).resolves.toBe(false);
expect(sandbox.getReplayed()).toBe(1);
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);
}
});
});