mirror of
https://github.com/motajs/template.git
synced 2026-09-12 09:48:49 +08:00
fix(03-05): complete data closure coverage and Node combat gate
This commit is contained in:
parent
64b97bc515
commit
232df083f7
@ -137,7 +137,13 @@ export class MainDamageCalculator implements IDamageCalculator<
|
||||
damage -= mdef;
|
||||
|
||||
// 未开启负伤时,如果伤害为负,则设为 0
|
||||
if (!core.flags.enableNegativeDamage && damage < 0) {
|
||||
if (
|
||||
!handler.state.flags.getFieldValueDefaults(
|
||||
'enableNegativeDamage',
|
||||
false
|
||||
) &&
|
||||
damage < 0
|
||||
) {
|
||||
damage = 0;
|
||||
}
|
||||
|
||||
|
||||
247
packages-user/data-state/test/dataClosure.test.ts
Normal file
247
packages-user/data-state/test/dataClosure.test.ts
Normal file
@ -0,0 +1,247 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
IEnemyAttr,
|
||||
IReplaySandbox,
|
||||
IHeroAttr,
|
||||
ReplaySystem,
|
||||
SaveCompression
|
||||
} 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 { REPLAY_COMMAND_ORDER, ReplayCommandCode } from '../src/replay/types';
|
||||
import { createClosedLoopFixture } from './fixtures/closed-loop';
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
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([
|
||||
ReplayCommandCode.Up,
|
||||
ReplayCommandCode.Right,
|
||||
ReplayCommandCode.Down,
|
||||
ReplayCommandCode.Left,
|
||||
ReplayCommandCode.AutoPathfindToPoint,
|
||||
ReplayCommandCode.UseItem,
|
||||
ReplayCommandCode.Equip,
|
||||
ReplayCommandCode.Unequip
|
||||
]);
|
||||
|
||||
state.replaySystem.record(ReplayCommandCode.Right, 4, 'route');
|
||||
expect(state.replaySystem.route.get(0)).toMatchObject({
|
||||
command: ReplayCommandCode.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.route,
|
||||
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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user