diff --git a/packages-user/data-system/src/combat/mapDamage.test.ts b/packages-user/data-system/src/combat/mapDamage.test.ts index 735b8db..6a97e10 100644 --- a/packages-user/data-system/src/combat/mapDamage.test.ts +++ b/packages-user/data-system/src/combat/mapDamage.test.ts @@ -1,4 +1,4 @@ -// 测试地图伤害:无来源伤害增删、有来源转换与合并、告警码 102/103/104、分离伤害合并、deleteEnemy 与 markEnemyDirty +// 测试地图伤害:无来源伤害增删、有来源转换与合并、告警码 102/103/104、分离伤害合并、deleteEnemy 与 markEnemyDirty、CR-02 空视图集/范围收缩、IN-01 删除后点残留 import { beforeAll, describe, expect, it, vi } from 'vitest'; import { type IRange, @@ -544,6 +544,85 @@ describe('MapDamage sourced conversion and reduction', () => { ...fixture.damage.getSeparatedDamage(fixture.locator) ]).toHaveLength(0); }); + + // 验证伤害来源消失后旧坐标不再返回幽灵伤害(CR-02 视图集变空) + it('drops ghost damage when the enemy view set becomes empty', () => { + const fixture = createFixture(); + fixture.damage.useReducer(fixture.reducer); + fixture.damage.useConverter(fixture.converter); + expect(fixture.damage.getReducedDamage(fixture.locator)?.damage).toBe( + 7 + ); + + fixture.converter.views.length = 0; + fixture.damage.markEnemyDirty(fixture.view); + + expect(fixture.damage.getReducedDamage(fixture.locator)).toBeNull(); + expect([ + ...fixture.damage.getSeparatedDamage(fixture.locator) + ]).toHaveLength(0); + }); + + // 验证伤害范围收缩后跌出范围的坐标不再保留旧缓存(CR-02 范围收缩) + it('drops stale damage on indexes that fall out of a shrunken range', () => { + const fixture = createFixture(); + const indexA = fixture.context.indexer.locToIndex( + fixture.locator.x, + fixture.locator.y + ); + const indexB = fixture.context.indexer.locToIndex(2, 1); + const locatorB: ITileLocator = { x: 2, y: 1 }; + const view = new FakeView(7, [indexA, indexB]); + fixture.damage.useReducer(fixture.reducer); + fixture.damage.useConverter(new FakeConverter([view])); + expect(fixture.damage.getReducedDamage(fixture.locator)?.damage).toBe( + 7 + ); + expect(fixture.damage.getReducedDamage(locatorB)?.damage).toBe(7); + + view.range.indexes.length = 1; + fixture.damage.markEnemyDirty(fixture.view); + + expect(fixture.damage.getReducedDamage(locatorB)).toBeNull(); + expect([...fixture.damage.getSeparatedDamage(locatorB)]).toHaveLength( + 0 + ); + expect(fixture.damage.getReducedDamage(fixture.locator)?.damage).toBe( + 7 + ); + }); + + // 验证视图集变空后仍登记空集,此后标记怪物脏走局部刷新而非整表刷新 + it('keeps refreshing locally after an empty view set is registered', () => { + const fixture = createFixture(); + fixture.damage.useReducer(fixture.reducer); + fixture.damage.useConverter(fixture.converter); + fixture.converter.views.length = 0; + fixture.damage.markEnemyDirty(fixture.view); + const iterateSpy = vi.spyOn(fixture.context, 'iterateEnemy'); + const calls = fixture.converter.calls; + + fixture.damage.markEnemyDirty(fixture.view); + + expect(iterateSpy).not.toHaveBeenCalled(); + expect(fixture.converter.calls).toBe(calls + 1); + }); + + // 验证 refreshAll 同样登记空视图集,此后标记怪物脏不退回整表刷新 + it('registers an empty view set through refreshAll as well', () => { + const fixture = createFixture(); + fixture.damage.useReducer(fixture.reducer); + fixture.damage.useConverter(fixture.converter); + fixture.converter.views.length = 0; + fixture.damage.refreshAll(); + const iterateSpy = vi.spyOn(fixture.context, 'iterateEnemy'); + const calls = fixture.converter.calls; + + fixture.damage.markEnemyDirty(fixture.view); + + expect(iterateSpy).not.toHaveBeenCalled(); + expect(fixture.converter.calls).toBe(calls + 1); + }); }); describe('MapDamage multi-source stacking', () => { diff --git a/packages-user/data-system/src/combat/mapDamage.ts b/packages-user/data-system/src/combat/mapDamage.ts index 465f758..b670b0b 100644 --- a/packages-user/data-system/src/combat/mapDamage.ts +++ b/packages-user/data-system/src/combat/mapDamage.ts @@ -240,10 +240,13 @@ export class MapDamage implements IMapDamage { private removeEnemyAffecting(view: IEnemyView) { const views = this.enemyStore.get(view); if (!views) return; + // 收集被移除贡献的坐标索引,遍历结束后统一标脏以清掉其旧合并缓存 + const removed = new Set(); views.forEach(viewItem => { const store = this.viewStore.get(viewItem); if (!store) return; store.damages.forEach((dam, index) => { + removed.add(index); const point = this.sourcedDamage.get(index); if (!point) return; point.affectedBy.delete(viewItem); @@ -253,6 +256,9 @@ export class MapDamage implements IMapDamage { this.viewStore.delete(viewItem); }); this.enemyStore.delete(view); + removed.forEach(index => { + this.markDirtyIndex(index); + }); } /** @@ -297,8 +303,9 @@ export class MapDamage implements IMapDamage { if (!handler) return; const views = this.converter.convert(handler, this.context); const set = new Set>(views); - if (set.size === 0) return; + // 空集也需要登记,否则该怪物此后标脏会退回整表刷新 this.enemyStore.set(view, set); + if (set.size === 0) return; const collection = new Set(); set.forEach(viewItem => { const range = viewItem.getRange(); @@ -342,8 +349,9 @@ export class MapDamage implements IMapDamage { if (!handler) return; const views = this.converter.convert(handler, this.context); const set = new Set>(views); - if (set.size === 0) return; + // 空集也需要登记,否则该怪物此后标脏会退回整表刷新 this.enemyStore.set(view, set); + if (set.size === 0) return; set.forEach(viewItem => { const range = viewItem.getRange(); const param = viewItem.getRangeParam();