fix(07-13): #CR-02 mark removed indexes dirty and register empty view sets

removeEnemyAffecting collects removed coordinate indexes and markDirtyIndex each; refreshEnemyAndClearCache/refreshEnemy register enemyStore.set(view, set) before the empty-set return so later markEnemyDirty uses local refresh instead of refreshAll(). Adds a Chinese comment before each new it.
This commit is contained in:
unanmed 2026-09-17 16:56:33 +08:00
parent 3da7863580
commit cff4291512
2 changed files with 90 additions and 3 deletions

View File

@ -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', () => {

View File

@ -240,10 +240,13 @@ export class MapDamage<TEnemy, THero> implements IMapDamage<TEnemy, THero> {
private removeEnemyAffecting(view: IEnemyView<TEnemy>) {
const views = this.enemyStore.get(view);
if (!views) return;
// 收集被移除贡献的坐标索引,遍历结束后统一标脏以清掉其旧合并缓存
const removed = new Set<number>();
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<TEnemy, THero> implements IMapDamage<TEnemy, THero> {
this.viewStore.delete(viewItem);
});
this.enemyStore.delete(view);
removed.forEach(index => {
this.markDirtyIndex(index);
});
}
/**
@ -297,8 +303,9 @@ export class MapDamage<TEnemy, THero> implements IMapDamage<TEnemy, THero> {
if (!handler) return;
const views = this.converter.convert(handler, this.context);
const set = new Set<IMapDamageView<any>>(views);
if (set.size === 0) return;
// 空集也需要登记,否则该怪物此后标脏会退回整表刷新
this.enemyStore.set(view, set);
if (set.size === 0) return;
const collection = new Set<number>();
set.forEach(viewItem => {
const range = viewItem.getRange();
@ -342,8 +349,9 @@ export class MapDamage<TEnemy, THero> implements IMapDamage<TEnemy, THero> {
if (!handler) return;
const views = this.converter.convert(handler, this.context);
const set = new Set<IMapDamageView<any>>(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();