mirror of
https://github.com/motajs/template.git
synced 2026-11-01 06:04:30 +08:00
- 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')
367 lines
12 KiB
TypeScript
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);
|
|
}
|
|
});
|
|
});
|