mirror of
https://github.com/motajs/template.git
synced 2026-10-14 08:40:16 +08:00
test(06-01): cover MapDamage and EnemyContext behavior
- add mapDamage.test.ts for sourceless add/delete, sourced conversion, reduction caching, warns 102/103/104 and the #06-01-2 deleteEnemy finding - add context.test.ts for the enemy registry (register/lookup/unknown-null/delete/resize/scan) and effect-registry add/remove behavior - extend the 06-TEST-FINDINGS.md sink with the #06-01-2 row
This commit is contained in:
parent
15f329b61d
commit
484da2db19
@ -24,3 +24,4 @@
|
||||
| 模块/接口 | 现象 | 最小复现 | 疑似原因 | 影响面 | 建议修复方向 | 关联 skip 用例 | 严重度 |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| `damage.ts` `DamageContext.calculateCritical` / `findNextCritical` | 产出的临界点 `info` 与 `nextValue` 不对应 | 假计算器 `damage = 100 - atk * 10`、`getCriticalLimit = 10`、当前 `atk = 0`;首个临界点 `nextValue = 1`(正确伤害应为 90),但 `info.damage = 0`、`damageDiff = -100` | `findNextCritical` 在 `middleInfo.damage < referenceDamage` 分支只更新 `right`,从不更新 `targetInfo`,`targetInfo` 一直停留在初始 `upperLimit` 处的计算结果 | 临界点查询(数值提示、战斗模拟)展示的伤害与对应属性值不匹配 | 在 `middleInfo.damage < referenceDamage` 分支同步 `targetInfo = middleInfo`,或二分结束后按最终 `right` 重新计算一次 | `damage.test.ts` `reports the damage info matching the yielded critical value`(#06-01-1) | 中 |
|
||||
| `mapDamage.ts` `MapDamage.deleteEnemy` | 删除怪物后,该怪物产生的有来源地图伤害仍然存在 | 注册一个怪物视图并转换出伤害(假视图伤害 7)→ `getSeparatedDamage` 有 1 条 → `deleteEnemy(view)` → `getSeparatedDamage` 仍有 1 条(正确应为 0) | `viewStore` 与 `damageStore` 只在读/删路径出现,全文件没有任何 `.set(...)` 写入,因此 `deleteEnemy` / `removeEnemyAffecting` 中 `viewStore.get(viewItem)` 恒为 `undefined`,清理循环被整体跳过;`sourcedDamage` 点位与 `affectedBy` 都不会被移除 | 怪物死亡/离场后地图伤害不消失,`getSeparatedDamage` / `getReducedDamage` 继续返回幽灵伤害,并会污染后续 `refreshIndex` 的重新聚合 | 在 `refreshEnemy` / `refreshEnemyAndClearCache` 建立 sourced 伤害时同步写入 `viewStore.set(viewItem, { damages, enemy })` 与 `damageStore.set(damage, { sourceView, sourceEnemy, index })`,或改为在 `deleteEnemy` 中直接遍历 `sourcedDamage` 的 `affectedBy` 反向清理 | `mapDamage.test.ts` `removes enemy-sourced damage when the enemy is deleted`(#06-01-2) | 中 |
|
||||
|
||||
339
packages-user/data-system/src/combat/context.test.ts
Normal file
339
packages-user/data-system/src/combat/context.test.ts
Normal file
@ -0,0 +1,339 @@
|
||||
// 测试怪物上下文:注册与查询、未知查找、删除、resize、范围遍历与各类效果注册表增删
|
||||
import { beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { type IEnemy, type IStateBase } from '@user/data-base';
|
||||
import {
|
||||
type IAuraConverter,
|
||||
type IEnemyCommonQueryEffect,
|
||||
type IEnemyFinalEffect,
|
||||
type IEnemySpecialQueryEffect
|
||||
} from './types';
|
||||
|
||||
vi.hoisted(() => {
|
||||
vi.stubGlobal('main', { replayChecking: true });
|
||||
vi.stubGlobal('location', { origin: 'http://localhost' });
|
||||
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;
|
||||
};
|
||||
Map.prototype.getOrInsert ??= function <K, V>(
|
||||
this: Map<K, V>,
|
||||
key: K,
|
||||
defaultValue: V
|
||||
): V {
|
||||
const existing = this.get(key);
|
||||
if (existing !== undefined) return existing;
|
||||
this.set(key, defaultValue);
|
||||
return defaultValue;
|
||||
};
|
||||
});
|
||||
|
||||
interface TestEnemyAttr {
|
||||
/** 怪物生命值 */
|
||||
hp: number;
|
||||
/** 怪物攻击力 */
|
||||
atk: number;
|
||||
/** 怪物防御力 */
|
||||
def: number;
|
||||
}
|
||||
|
||||
interface TestHeroAttr {
|
||||
/** 勇士生命值 */
|
||||
hp: number;
|
||||
/** 勇士攻击力 */
|
||||
atk: number;
|
||||
/** 勇士防御力 */
|
||||
def: number;
|
||||
}
|
||||
|
||||
interface TestModules {
|
||||
EnemyContext: typeof import('./context').EnemyContext;
|
||||
Enemy: typeof import('@user/data-base').Enemy;
|
||||
HeroAttribute: typeof import('@user/data-base').HeroAttribute;
|
||||
}
|
||||
|
||||
let modules: TestModules;
|
||||
|
||||
beforeAll(async () => {
|
||||
vi.stubGlobal('main', { replayChecking: true });
|
||||
vi.stubGlobal('location', { origin: 'http://localhost' });
|
||||
const contextModule = await import('./context');
|
||||
const baseModule = await import('@user/data-base');
|
||||
modules = {
|
||||
EnemyContext: contextModule.EnemyContext,
|
||||
Enemy: baseModule.Enemy,
|
||||
HeroAttribute: baseModule.HeroAttribute
|
||||
};
|
||||
});
|
||||
|
||||
interface FakeSpecial {
|
||||
/** 特殊属性代码 */
|
||||
readonly code: number;
|
||||
|
||||
/**
|
||||
* 深拷贝此特殊属性
|
||||
*/
|
||||
clone(): FakeSpecial;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建一个最小特殊属性对象,只用于驱动注册表的匹配路径
|
||||
* @param code 特殊属性代码
|
||||
*/
|
||||
function createSpecial(code: number): FakeSpecial {
|
||||
return { code, clone: () => createSpecial(code) };
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建一个怪物对象
|
||||
* @param id 怪物 id
|
||||
* @param code 怪物图块数字
|
||||
*/
|
||||
function createEnemy(id: string, code: number): IEnemy<TestEnemyAttr> {
|
||||
return new modules.Enemy<TestEnemyAttr>(id, code, {
|
||||
hp: 10,
|
||||
atk: 2,
|
||||
def: 0
|
||||
});
|
||||
}
|
||||
|
||||
interface ContextFixture {
|
||||
/** 被测怪物上下文 */
|
||||
context: InstanceType<TestModules['EnemyContext']>;
|
||||
/** 可修改勇士属性 */
|
||||
hero: InstanceType<TestModules['HeroAttribute']>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建怪物上下文测试夹具,尺寸默认按 4x3 初始化
|
||||
* @param width 上下文宽度
|
||||
* @param height 上下文高度
|
||||
*/
|
||||
function createContext(width: number = 4, height: number = 3): ContextFixture {
|
||||
const state = {} as IStateBase;
|
||||
const context = new modules.EnemyContext<TestEnemyAttr, TestHeroAttr>(
|
||||
state
|
||||
);
|
||||
context.resize(width, height);
|
||||
const hero = new modules.HeroAttribute<TestHeroAttr>({
|
||||
hp: 100,
|
||||
atk: 0,
|
||||
def: 0
|
||||
});
|
||||
return { context, hero };
|
||||
}
|
||||
|
||||
describe('EnemyContext registry', () => {
|
||||
// 验证注册怪物后可按定位符、坐标与计算后怪物反查
|
||||
it('registers an enemy and resolves it from locator, point and computed', () => {
|
||||
const fixture = createContext();
|
||||
const enemy = createEnemy('e1', 1);
|
||||
|
||||
fixture.context.setEnemyAt({ x: 1, y: 0 }, enemy);
|
||||
const view = fixture.context.getEnemyByLocator({ x: 1, y: 0 });
|
||||
|
||||
expect(view).not.toBeNull();
|
||||
expect(fixture.context.getEnemyByLoc(1, 0)).toBe(view);
|
||||
expect(fixture.context.getEnemyLocatorByView(view!)).toEqual({
|
||||
x: 1,
|
||||
y: 0
|
||||
});
|
||||
expect(fixture.context.getEnemyLocator(enemy)).toEqual({ x: 1, y: 0 });
|
||||
expect(fixture.context.getViewByComputed(view!.getComputedEnemy())).toBe(
|
||||
view
|
||||
);
|
||||
});
|
||||
|
||||
// 验证未知定位符、未知坐标与未知怪物对象均返回 null
|
||||
it('returns null for unknown lookups', () => {
|
||||
const fixture = createContext();
|
||||
const other = createEnemy('other', 2);
|
||||
|
||||
expect(fixture.context.getEnemyByLocator({ x: 3, y: 2 })).toBeNull();
|
||||
expect(fixture.context.getEnemyByLoc(3, 2)).toBeNull();
|
||||
expect(fixture.context.getViewByComputed(other)).toBeNull();
|
||||
expect(fixture.context.getEnemyLocator(other)).toBeNull();
|
||||
});
|
||||
|
||||
// 验证同一坐标重复注册会替换原有怪物及其映射
|
||||
it('replaces the enemy registered at the same point', () => {
|
||||
const fixture = createContext();
|
||||
const first = createEnemy('first', 1);
|
||||
const second = createEnemy('second', 2);
|
||||
|
||||
fixture.context.setEnemyAt({ x: 0, y: 0 }, first);
|
||||
fixture.context.setEnemyAt({ x: 0, y: 0 }, second);
|
||||
|
||||
expect(fixture.context.getEnemyLocator(first)).toBeNull();
|
||||
expect(fixture.context.getEnemyLocator(second)).toEqual({ x: 0, y: 0 });
|
||||
});
|
||||
|
||||
// 验证删除怪物会同时移除视图与定位符映射
|
||||
it('removes view and locator mappings when deleting an enemy', () => {
|
||||
const fixture = createContext();
|
||||
const enemy = createEnemy('e1', 1);
|
||||
fixture.context.setEnemyAt({ x: 1, y: 0 }, enemy);
|
||||
const view = fixture.context.getEnemyByLocator({ x: 1, y: 0 })!;
|
||||
const computed = view.getComputedEnemy();
|
||||
|
||||
fixture.context.deleteEnemy({ x: 1, y: 0 });
|
||||
|
||||
expect(fixture.context.getEnemyByLocator({ x: 1, y: 0 })).toBeNull();
|
||||
expect(fixture.context.getEnemyLocatorByView(view)).toBeNull();
|
||||
expect(fixture.context.getEnemyLocator(enemy)).toBeNull();
|
||||
expect(fixture.context.getViewByComputed(computed)).toBeNull();
|
||||
});
|
||||
|
||||
// 验证 resize 会清空怪物并更新尺寸与索引宽度
|
||||
it('resizes by clearing enemies and updating dimensions', () => {
|
||||
const fixture = createContext(4, 3);
|
||||
fixture.context.setEnemyAt({ x: 1, y: 0 }, createEnemy('e1', 1));
|
||||
|
||||
fixture.context.resize(5, 2);
|
||||
|
||||
expect(fixture.context.width).toBe(5);
|
||||
expect(fixture.context.height).toBe(2);
|
||||
expect(fixture.context.indexer.locToIndex(3, 1)).toBe(8);
|
||||
expect(fixture.context.getEnemyByLocator({ x: 1, y: 0 })).toBeNull();
|
||||
});
|
||||
|
||||
// 验证 scanRange 与 iterateEnemy 会遍历全部已注册怪物
|
||||
it('scans and iterates all registered enemies', () => {
|
||||
const fixture = createContext();
|
||||
fixture.context.setEnemyAt({ x: 0, y: 0 }, createEnemy('first', 1));
|
||||
fixture.context.setEnemyAt({ x: 2, y: 1 }, createEnemy('second', 2));
|
||||
const range = {
|
||||
bindHost: () => {},
|
||||
autoDetect: (list: Set<number>) => [...list]
|
||||
};
|
||||
|
||||
const scanned = [...fixture.context.scanRange(range as never, 0)];
|
||||
const iterated = [...fixture.context.iterateEnemy()];
|
||||
|
||||
expect(scanned).toHaveLength(2);
|
||||
expect(iterated).toHaveLength(2);
|
||||
expect(iterated.map(([locator]) => locator)).toEqual([
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 2, y: 1 }
|
||||
]);
|
||||
});
|
||||
|
||||
// 验证绑定与解绑勇士对象会同步更新查询结果
|
||||
it('binds and clears the bound hero', () => {
|
||||
const fixture = createContext();
|
||||
|
||||
fixture.context.bindHero(fixture.hero);
|
||||
expect(fixture.context.getBindedHero()).toBe(fixture.hero);
|
||||
|
||||
fixture.context.bindHero(null);
|
||||
expect(fixture.context.getBindedHero()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('EnemyContext effect registries', () => {
|
||||
// 验证注册的最终效果会在构建时执行,注销后不再执行
|
||||
it('runs registered final effects until they are unregistered', () => {
|
||||
const fixture = createContext();
|
||||
fixture.context.bindHero(fixture.hero);
|
||||
fixture.context.setEnemyAt({ x: 0, y: 0 }, createEnemy('e1', 1));
|
||||
const applied: number[] = [];
|
||||
const effect: IEnemyFinalEffect<TestEnemyAttr, TestHeroAttr> = {
|
||||
priority: 1,
|
||||
apply: () => {
|
||||
applied.push(1);
|
||||
}
|
||||
};
|
||||
|
||||
fixture.context.registerFinalEffect(effect);
|
||||
fixture.context.buildup();
|
||||
expect(applied).toHaveLength(1);
|
||||
|
||||
fixture.context.unregisterFinalEffect(effect);
|
||||
fixture.context.buildup();
|
||||
expect(applied).toHaveLength(1);
|
||||
});
|
||||
|
||||
// 验证注册的常规查询效果会按特殊属性代码执行,注销后不再执行
|
||||
it('runs common query effects for matching specials until unregistered', () => {
|
||||
const fixture = createContext();
|
||||
fixture.context.bindHero(fixture.hero);
|
||||
const enemy = createEnemy('e1', 1);
|
||||
enemy.addSpecial(createSpecial(20) as never);
|
||||
fixture.context.setEnemyAt({ x: 0, y: 0 }, enemy);
|
||||
const queried: number[] = [];
|
||||
const effect: IEnemyCommonQueryEffect<TestEnemyAttr, TestHeroAttr> = {
|
||||
priority: 1,
|
||||
apply: () => {
|
||||
queried.push(1);
|
||||
}
|
||||
};
|
||||
|
||||
fixture.context.registerCommonQueryEffect(20, effect);
|
||||
fixture.context.buildup();
|
||||
expect(queried).toHaveLength(1);
|
||||
|
||||
fixture.context.unregisterCommonQueryEffect(20, effect);
|
||||
fixture.context.buildup();
|
||||
expect(queried).toHaveLength(1);
|
||||
});
|
||||
|
||||
// 验证注册的特殊查询效果会在构建时构造修饰器,注销后不再构造
|
||||
it('builds special query modifiers until unregistered', () => {
|
||||
const fixture = createContext();
|
||||
fixture.context.bindHero(fixture.hero);
|
||||
fixture.context.setEnemyAt({ x: 0, y: 0 }, createEnemy('e1', 1));
|
||||
const forCalls: number[] = [];
|
||||
const modifier = {
|
||||
add: () => [],
|
||||
delete: () => [],
|
||||
modify: () => false,
|
||||
shouldQuery: () => false
|
||||
};
|
||||
const effect: IEnemySpecialQueryEffect<TestEnemyAttr, TestHeroAttr> = {
|
||||
priority: 5,
|
||||
for: () => {
|
||||
forCalls.push(1);
|
||||
return modifier;
|
||||
}
|
||||
};
|
||||
|
||||
fixture.context.registerSpecialQueryEffect(effect);
|
||||
fixture.context.buildup();
|
||||
expect(forCalls).toHaveLength(1);
|
||||
|
||||
fixture.context.unregisterSpecialQueryEffect(effect);
|
||||
fixture.context.buildup();
|
||||
expect(forCalls).toHaveLength(1);
|
||||
});
|
||||
|
||||
// 验证注册的光环转换器会参与特殊属性转换,注销后不再参与
|
||||
it('consults aura converters until they are unregistered', () => {
|
||||
const fixture = createContext();
|
||||
fixture.context.bindHero(fixture.hero);
|
||||
const enemy = createEnemy('e1', 1);
|
||||
enemy.addSpecial(createSpecial(20) as never);
|
||||
fixture.context.setEnemyAt({ x: 0, y: 0 }, enemy);
|
||||
const consulted: number[] = [];
|
||||
const converter: IAuraConverter<TestEnemyAttr, TestHeroAttr> = {
|
||||
shouldConvert: () => {
|
||||
consulted.push(1);
|
||||
return false;
|
||||
},
|
||||
convert: () => null as never
|
||||
};
|
||||
|
||||
fixture.context.registerAuraConverter(converter);
|
||||
fixture.context.buildup();
|
||||
expect(consulted).toHaveLength(1);
|
||||
|
||||
fixture.context.unregisterAuraConverter(converter);
|
||||
fixture.context.buildup();
|
||||
expect(consulted).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
440
packages-user/data-system/src/combat/mapDamage.test.ts
Normal file
440
packages-user/data-system/src/combat/mapDamage.test.ts
Normal file
@ -0,0 +1,440 @@
|
||||
// 测试地图伤害:无来源伤害增删、有来源转换与合并、告警码 102/103/104、分离伤害合并与 deleteEnemy
|
||||
import { beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
type IRange,
|
||||
type ITileLocator,
|
||||
type IRangeHost
|
||||
} from '@motajs/common';
|
||||
import { type IEnemy, type IStateBase } from '@user/data-base';
|
||||
import {
|
||||
type IEnemyContext,
|
||||
type IEnemyView,
|
||||
type IMapDamageConverter,
|
||||
type IMapDamageInfo,
|
||||
type IMapDamageReducer,
|
||||
type IMapDamageView
|
||||
} from './types';
|
||||
|
||||
vi.hoisted(() => {
|
||||
vi.stubGlobal('main', { replayChecking: true });
|
||||
vi.stubGlobal('location', { origin: 'http://localhost' });
|
||||
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;
|
||||
};
|
||||
Map.prototype.getOrInsert ??= function <K, V>(
|
||||
this: Map<K, V>,
|
||||
key: K,
|
||||
defaultValue: V
|
||||
): V {
|
||||
const existing = this.get(key);
|
||||
if (existing !== undefined) return existing;
|
||||
this.set(key, defaultValue);
|
||||
return defaultValue;
|
||||
};
|
||||
});
|
||||
|
||||
interface TestEnemyAttr {
|
||||
/** 怪物生命值 */
|
||||
hp: number;
|
||||
/** 怪物攻击力 */
|
||||
atk: number;
|
||||
/** 怪物防御力 */
|
||||
def: number;
|
||||
}
|
||||
|
||||
interface TestHeroAttr {
|
||||
/** 勇士生命值 */
|
||||
hp: number;
|
||||
/** 勇士攻击力 */
|
||||
atk: number;
|
||||
/** 勇士防御力 */
|
||||
def: number;
|
||||
}
|
||||
|
||||
interface TestModules {
|
||||
MapDamage: typeof import('./mapDamage').MapDamage;
|
||||
MapLocIndexer: typeof import('@user/data-common').MapLocIndexer;
|
||||
HeroAttribute: typeof import('@user/data-base').HeroAttribute;
|
||||
Enemy: typeof import('@user/data-base').Enemy;
|
||||
logger: typeof import('@motajs/common').logger;
|
||||
}
|
||||
|
||||
let modules: TestModules;
|
||||
|
||||
beforeAll(async () => {
|
||||
vi.stubGlobal('main', { replayChecking: true });
|
||||
vi.stubGlobal('location', { origin: 'http://localhost' });
|
||||
const mapDamageModule = await import('./mapDamage');
|
||||
const commonModule = await import('@user/data-common');
|
||||
const motaModule = await import('@motajs/common');
|
||||
const baseModule = await import('@user/data-base');
|
||||
modules = {
|
||||
MapDamage: mapDamageModule.MapDamage,
|
||||
MapLocIndexer: commonModule.MapLocIndexer,
|
||||
HeroAttribute: baseModule.HeroAttribute,
|
||||
Enemy: baseModule.Enemy,
|
||||
logger: motaModule.logger
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* 构造一条地图伤害信息
|
||||
* @param damage 伤害值
|
||||
*/
|
||||
function createInfo(damage: number): IMapDamageInfo {
|
||||
return {
|
||||
damage,
|
||||
type: 0,
|
||||
extra: { catch: new Set(), repulse: new Set() }
|
||||
};
|
||||
}
|
||||
|
||||
interface IFakeRange {
|
||||
/**
|
||||
* 绑定宿主对象
|
||||
* @param host 宿主对象
|
||||
*/
|
||||
bindHost(host: IRangeHost): void;
|
||||
|
||||
/**
|
||||
* 迭代范围内的坐标索引
|
||||
* @param param 范围参数
|
||||
*/
|
||||
iterateLoc(param: number): Iterable<number>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 只覆盖固定坐标索引集合的测试范围,用于驱动有来源伤害转换
|
||||
*/
|
||||
class FakeRange implements IFakeRange {
|
||||
/** 该范围覆盖的坐标索引 */
|
||||
readonly indexes: number[];
|
||||
|
||||
constructor(indexes: number[]) {
|
||||
this.indexes = indexes;
|
||||
}
|
||||
|
||||
bindHost(_host: IRangeHost): void {}
|
||||
|
||||
*iterateLoc(): Iterable<number> {
|
||||
yield* this.indexes;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 固定返回一条地图伤害的测试视图
|
||||
*/
|
||||
class FakeView implements IMapDamageView<number> {
|
||||
/** 该视图产生的伤害值 */
|
||||
readonly value: number;
|
||||
/** 该视图影响的范围 */
|
||||
readonly range: FakeRange;
|
||||
|
||||
constructor(value: number, indexes: number[]) {
|
||||
this.value = value;
|
||||
this.range = new FakeRange(indexes);
|
||||
}
|
||||
|
||||
getRange(): IRange<number> {
|
||||
return this.range as never;
|
||||
}
|
||||
|
||||
getRangeParam(): number {
|
||||
return 0;
|
||||
}
|
||||
|
||||
getDamageAt(_locator: ITileLocator): Readonly<IMapDamageInfo> | null {
|
||||
return createInfo(this.value);
|
||||
}
|
||||
|
||||
getDamageWithoutCheck(
|
||||
_locator: ITileLocator
|
||||
): Readonly<IMapDamageInfo> | null {
|
||||
return createInfo(this.value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 固定输出伤害视图列表的测试转换器
|
||||
*/
|
||||
class FakeConverter
|
||||
implements IMapDamageConverter<TestEnemyAttr, TestHeroAttr>
|
||||
{
|
||||
/** 转换输出的伤害视图 */
|
||||
readonly views: IMapDamageView<number>[];
|
||||
|
||||
constructor(views: IMapDamageView<number>[]) {
|
||||
this.views = views;
|
||||
}
|
||||
|
||||
convert(): IMapDamageView<number>[] {
|
||||
return this.views;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 求和式测试合并器,并记录调用次数
|
||||
*/
|
||||
class FakeReducer implements IMapDamageReducer {
|
||||
/** reduce 调用次数 */
|
||||
calls: number = 0;
|
||||
|
||||
reduce(
|
||||
info: Iterable<Readonly<IMapDamageInfo>>
|
||||
): Readonly<IMapDamageInfo> {
|
||||
this.calls++;
|
||||
let damage = 0;
|
||||
for (const item of info) {
|
||||
damage += item.damage;
|
||||
}
|
||||
return createInfo(damage);
|
||||
}
|
||||
}
|
||||
|
||||
interface MapDamageFixture {
|
||||
/** 被测地图伤害对象 */
|
||||
damage: InstanceType<TestModules['MapDamage']>;
|
||||
/** 怪物上下文假对象 */
|
||||
context: IEnemyContext<TestEnemyAttr, TestHeroAttr>;
|
||||
/** 测试转换器 */
|
||||
converter: FakeConverter;
|
||||
/** 测试合并器 */
|
||||
reducer: FakeReducer;
|
||||
/** 已注册的怪物视图 */
|
||||
view: IEnemyView<TestEnemyAttr>;
|
||||
/** 转换出的伤害视图 */
|
||||
damageView: FakeView;
|
||||
/** 已注册怪物的位置 */
|
||||
locator: ITileLocator;
|
||||
/** 可修改原始怪物 */
|
||||
origin: IEnemy<TestEnemyAttr>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建地图伤害测试夹具,怪物上下文只实现被测路径所需的最小接口
|
||||
*/
|
||||
function createFixture(): MapDamageFixture {
|
||||
const state = {} as IStateBase;
|
||||
const indexer = new modules.MapLocIndexer();
|
||||
indexer.setWidth(4);
|
||||
const hero = new modules.HeroAttribute<TestHeroAttr>({
|
||||
hp: 100,
|
||||
atk: 0,
|
||||
def: 0
|
||||
});
|
||||
const origin = new modules.Enemy<TestEnemyAttr>('enemy-1', 1, {
|
||||
hp: 10,
|
||||
atk: 2,
|
||||
def: 0
|
||||
});
|
||||
const locator: ITileLocator = { x: 1, y: 0 };
|
||||
const enemies = new Map<IEnemyView<TestEnemyAttr>, ITileLocator>();
|
||||
const view: IEnemyView<TestEnemyAttr> = {
|
||||
context: {} as never,
|
||||
reset: () => {},
|
||||
getBaseEnemy: () => origin,
|
||||
getComputedEnemy: () => origin,
|
||||
getModifiableEnemy: () => origin,
|
||||
markDirty: () => {}
|
||||
};
|
||||
enemies.set(view, locator);
|
||||
const damageView = new FakeView(
|
||||
7,
|
||||
[indexer.locToIndex(locator.x, locator.y)]
|
||||
);
|
||||
const context = {
|
||||
state,
|
||||
indexer,
|
||||
width: 4,
|
||||
height: 3,
|
||||
getBindedHero: () => hero,
|
||||
getEnemyLocatorByView: (target: IEnemyView<TestEnemyAttr>) =>
|
||||
enemies.get(target) ?? null,
|
||||
getEnemyLocator: (target: IEnemy<TestEnemyAttr>) =>
|
||||
target === origin ? locator : null,
|
||||
getViewByComputed: () => view,
|
||||
*iterateEnemy(): Iterable<
|
||||
[ITileLocator, IEnemyView<TestEnemyAttr>]
|
||||
> {
|
||||
for (const [target, targetLocator] of enemies) {
|
||||
yield [targetLocator, target];
|
||||
}
|
||||
}
|
||||
} as never;
|
||||
const damage = new modules.MapDamage<TestEnemyAttr, TestHeroAttr>(
|
||||
context
|
||||
);
|
||||
return {
|
||||
damage,
|
||||
context,
|
||||
converter: new FakeConverter([damageView]),
|
||||
reducer: new FakeReducer(),
|
||||
view,
|
||||
damageView,
|
||||
locator,
|
||||
origin
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建一个未注册到夹具中的怪物视图,用于验证未知定位符路径
|
||||
*/
|
||||
function createUnknownView(fixture: MapDamageFixture): IEnemyView<TestEnemyAttr> {
|
||||
return {
|
||||
context: {} as never,
|
||||
reset: () => {},
|
||||
getBaseEnemy: () => fixture.origin,
|
||||
getComputedEnemy: () => fixture.origin,
|
||||
getModifiableEnemy: () => fixture.origin,
|
||||
markDirty: () => {}
|
||||
};
|
||||
}
|
||||
|
||||
describe('MapDamage sourceless damage', () => {
|
||||
// 验证添加与删除无来源地图伤害会同步更新分离伤害列表
|
||||
it('adds and deletes sourceless damage at a point', () => {
|
||||
const fixture = createFixture();
|
||||
fixture.damage.useReducer(fixture.reducer);
|
||||
const info = createInfo(5);
|
||||
|
||||
fixture.damage.addMapDamage({ x: 0, y: 0 }, info);
|
||||
expect(
|
||||
[...fixture.damage.getSeparatedDamage({ x: 0, y: 0 })]
|
||||
).toContain(info);
|
||||
|
||||
fixture.damage.deleteMapDamage({ x: 0, y: 0 }, info);
|
||||
expect(
|
||||
[...fixture.damage.getSeparatedDamage({ x: 0, y: 0 })]
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
// 验证删除不存在的无来源伤害是安全操作
|
||||
it('ignores deleting sourceless damage that was never added', () => {
|
||||
const fixture = createFixture();
|
||||
fixture.damage.useReducer(fixture.reducer);
|
||||
|
||||
fixture.damage.deleteMapDamage({ x: 3, y: 2 }, createInfo(5));
|
||||
|
||||
expect(
|
||||
[...fixture.damage.getSeparatedDamage({ x: 3, y: 2 })]
|
||||
).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MapDamage sourced conversion and reduction', () => {
|
||||
// 验证转换出的有来源伤害可被分离查询与合并查询获取
|
||||
it('converts sourced damage and reduces it through the reducer', () => {
|
||||
const fixture = createFixture();
|
||||
fixture.damage.useReducer(fixture.reducer);
|
||||
fixture.damage.useConverter(fixture.converter);
|
||||
|
||||
const separated = [
|
||||
...fixture.damage.getSeparatedDamage(fixture.locator)
|
||||
];
|
||||
const reduced = fixture.damage.getReducedDamage(fixture.locator);
|
||||
|
||||
expect(separated).toHaveLength(1);
|
||||
expect(separated[0].damage).toBe(7);
|
||||
expect(reduced?.damage).toBe(7);
|
||||
expect(fixture.reducer.calls).toBe(1);
|
||||
});
|
||||
|
||||
// 验证同一坐标上无来源与有来源伤害会被合并查询同时返回
|
||||
it('unions sourceless and sourced damage at the same point', () => {
|
||||
const fixture = createFixture();
|
||||
fixture.damage.useReducer(fixture.reducer);
|
||||
fixture.damage.useConverter(fixture.converter);
|
||||
fixture.damage.addMapDamage(fixture.locator, createInfo(3));
|
||||
|
||||
expect(
|
||||
[...fixture.damage.getSeparatedDamage(fixture.locator)]
|
||||
).toHaveLength(2);
|
||||
|
||||
const reduced = fixture.damage.getReducedDamage(fixture.locator);
|
||||
expect(reduced?.damage).toBe(10);
|
||||
});
|
||||
|
||||
// 验证合并结果命中缓存,markDirty 后按新数据重新合并
|
||||
it('caches the reduced damage until the point is marked dirty', () => {
|
||||
const fixture = createFixture();
|
||||
fixture.damage.useReducer(fixture.reducer);
|
||||
fixture.damage.addMapDamage({ x: 0, y: 0 }, createInfo(3));
|
||||
|
||||
const first = fixture.damage.getReducedDamage({ x: 0, y: 0 });
|
||||
const second = fixture.damage.getReducedDamage({ x: 0, y: 0 });
|
||||
|
||||
expect(second).toBe(first);
|
||||
expect(fixture.reducer.calls).toBe(1);
|
||||
|
||||
fixture.damage.markDirty({ x: 0, y: 0 });
|
||||
const third = fixture.damage.getReducedDamage({ x: 0, y: 0 });
|
||||
|
||||
expect(third).not.toBe(first);
|
||||
expect(third?.damage).toBe(3);
|
||||
expect(fixture.reducer.calls).toBe(2);
|
||||
});
|
||||
|
||||
// 验证没有任何分离伤害时合并查询返回 null
|
||||
it('returns null when a point has no separated damage', () => {
|
||||
const fixture = createFixture();
|
||||
fixture.damage.useReducer(fixture.reducer);
|
||||
|
||||
expect(fixture.damage.getReducedDamage({ x: 3, y: 2 })).toBeNull();
|
||||
});
|
||||
|
||||
// 验证未设置转换器时全量刷新告警 102 并直接返回
|
||||
it('warns 102 when the converter is missing during a full refresh', () => {
|
||||
const fixture = createFixture();
|
||||
|
||||
const result = modules.logger.catch(() => fixture.damage.refreshAll());
|
||||
|
||||
expect(result.info.map(v => v.code)).toContain(102);
|
||||
});
|
||||
|
||||
// 验证未设置合并器时合并查询告警 103 并返回 null
|
||||
it('warns 103 when the reducer is missing', () => {
|
||||
const fixture = createFixture();
|
||||
|
||||
const result = modules.logger.catch(() =>
|
||||
fixture.damage.getReducedDamage({ x: 0, y: 0 })
|
||||
);
|
||||
|
||||
expect(result.ret).toBeNull();
|
||||
expect(result.info.map(v => v.code)).toContain(103);
|
||||
});
|
||||
|
||||
// 验证既无伤害来源又无定位符时标记怪物脏会告警 104
|
||||
it('warns 104 when marking an unregistered enemy dirty', () => {
|
||||
const fixture = createFixture();
|
||||
|
||||
const result = modules.logger.catch(() =>
|
||||
fixture.damage.markEnemyDirty(createUnknownView(fixture))
|
||||
);
|
||||
|
||||
expect(result.info.map(v => v.code)).toContain(104);
|
||||
});
|
||||
|
||||
// 疑似 bug:deleteEnemy 应移除该怪物带来的有来源地图伤害,详见 06-TEST-FINDINGS.md #06-01-2,修复后取消 skip
|
||||
it.skip('removes enemy-sourced damage when the enemy is deleted', () => {
|
||||
const fixture = createFixture();
|
||||
fixture.damage.useReducer(fixture.reducer);
|
||||
fixture.damage.useConverter(fixture.converter);
|
||||
expect(
|
||||
[...fixture.damage.getSeparatedDamage(fixture.locator)]
|
||||
).toHaveLength(1);
|
||||
|
||||
fixture.damage.deleteEnemy(fixture.view);
|
||||
|
||||
expect(
|
||||
[...fixture.damage.getSeparatedDamage(fixture.locator)]
|
||||
).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user