diff --git a/packages-user/data-state/src/enemy/calculator.ts b/packages-user/data-state/src/enemy/calculator.ts index 7563a18..d81c8e3 100644 --- a/packages-user/data-state/src/enemy/calculator.ts +++ b/packages-user/data-state/src/enemy/calculator.ts @@ -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; } diff --git a/packages-user/data-state/test/dataClosure.test.ts b/packages-user/data-state/test/dataClosure.test.ts new file mode 100644 index 0000000..a86b42e --- /dev/null +++ b/packages-user/data-state/test/dataClosure.test.ts @@ -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 ( + this: Map, + 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 ( + this: Map, + 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 { + let attrs: IEnemyAttr = { + hp: 20, + atk: 8, + def: 5, + money: 2, + exp: 3, + point: 1, + guard: new Set() + }; + const enemy: IEnemy = { + 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 { + 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 { + for (let index = 0; index < 1000 && !sandbox.ended; index++) { + await new Promise(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('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; + const handler = { + enemy, + context: state.enemyContext, + locator: { x: 0, y: 0 }, + hero, + state + } as IReadonlyEnemyHandler; + + 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); + }); +});