mirror of
https://github.com/motajs/template.git
synced 2026-09-19 05:30:16 +08:00
359 lines
12 KiB
TypeScript
359 lines
12 KiB
TypeScript
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);
|
||
}
|
||
});
|
||
});
|