mirror of
https://github.com/motajs/template.git
synced 2026-09-23 16:50:19 +08:00
test(06-01): cover CombatFlow binding, script ordering and guards
- add combat.test.ts for same-state binding, foreign-state warn 138, script priority sort and duplicate warn 140, guard warns 139/141, awaited script/hook ordering with real timers, and battleComputed delegation - extend the 06-TEST-FINDINGS.md sink with the #06-01-3 before-return semantics row
This commit is contained in:
parent
484da2db19
commit
6c8f85a2be
@ -25,3 +25,4 @@
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| `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) | 中 |
|
||||
| `combat.ts` `CombatFlow.combatFlow` / `ICombatScript.before` | 实现与接口文档语义相反:接口声明返回 `false` 放弃战斗,实现却在返回**真值**时短路 | 加载一个 `onBeforeCombat` 钩子,脚本 `before` 返回 `false`;`battle()` 仍会执行该钩子(正确实现应直接返回并放弃) | `const skip = await script.before(...); if (skip) return damage;` 以真值短路,与文档「返回 false 会立刻停止并放弃此次战斗」相反;文档或实现其一有误 | 所有战斗脚本的短路约定:按接口文档编写的脚本会得到相反效果,可能漏战或误战 | 先与接口设计者确认语义,再二选一统一:以 `true` 短路为准则修正接口注释,以文档为准则改为 `if (!skip) return damage;` | `combat.test.ts` `abandons the battle when the before script returns false`(#06-01-3) | 中 |
|
||||
|
||||
439
packages-user/data-system/src/combat/combat.test.ts
Normal file
439
packages-user/data-system/src/combat/combat.test.ts
Normal file
@ -0,0 +1,439 @@
|
||||
// 测试战斗流程:同源绑定与外来绑定、脚本优先级与去重、缺参告警 139/141、真实计时器下的 await 顺序
|
||||
import { beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { type ITileLocator } from '@motajs/common';
|
||||
import {
|
||||
type IEnemy,
|
||||
type IReadonlyEnemy,
|
||||
type IStateBase
|
||||
} from '@user/data-base';
|
||||
import {
|
||||
type ICombatScript,
|
||||
type IDamageContext,
|
||||
type IEnemyContext,
|
||||
type IEnemyDamageInfo,
|
||||
type IEnemyView
|
||||
} 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 {
|
||||
CombatFlow: typeof import('./combat').CombatFlow;
|
||||
Enemy: typeof import('@user/data-base').Enemy;
|
||||
HeroAttribute: typeof import('@user/data-base').HeroAttribute;
|
||||
logger: typeof import('@motajs/common').logger;
|
||||
}
|
||||
|
||||
let modules: TestModules;
|
||||
|
||||
beforeAll(async () => {
|
||||
vi.stubGlobal('main', { replayChecking: true });
|
||||
vi.stubGlobal('location', { origin: 'http://localhost' });
|
||||
const combatModule = await import('./combat');
|
||||
const baseModule = await import('@user/data-base');
|
||||
const motaModule = await import('@motajs/common');
|
||||
modules = {
|
||||
CombatFlow: combatModule.CombatFlow,
|
||||
Enemy: baseModule.Enemy,
|
||||
HeroAttribute: baseModule.HeroAttribute,
|
||||
logger: motaModule.logger
|
||||
};
|
||||
});
|
||||
|
||||
interface Deferred {
|
||||
/** 由外部释放的等待 promise */
|
||||
readonly promise: Promise<void>;
|
||||
|
||||
/**
|
||||
* 释放等待
|
||||
*/
|
||||
resolve(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建一个可手动释放的异步闸门,用于验证真实计时器下的 await 顺序
|
||||
*/
|
||||
function createDeferred(): Deferred {
|
||||
let resolve: () => void = () => {};
|
||||
const promise = new Promise<void>(r => {
|
||||
resolve = r;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录调用顺序的测试战斗脚本
|
||||
*/
|
||||
class FakeScript implements ICombatScript<TestEnemyAttr, TestHeroAttr> {
|
||||
readonly priority: number;
|
||||
/** 用于区分同一次运行内的多个脚本 */
|
||||
readonly label: string;
|
||||
/** 共享的调用顺序记录 */
|
||||
readonly calls: string[];
|
||||
/** before 的返回值,真值表示短路 */
|
||||
beforeResult: boolean;
|
||||
/** before 需要等待的异步闸门 */
|
||||
gate: Deferred | null = null;
|
||||
|
||||
constructor(
|
||||
priority: number,
|
||||
label: string,
|
||||
calls: string[],
|
||||
beforeResult: boolean = false
|
||||
) {
|
||||
this.priority = priority;
|
||||
this.label = label;
|
||||
this.calls = calls;
|
||||
this.beforeResult = beforeResult;
|
||||
}
|
||||
|
||||
async before(): Promise<boolean> {
|
||||
this.calls.push(`${this.label}.before`);
|
||||
if (this.gate) await this.gate.promise;
|
||||
return this.beforeResult;
|
||||
}
|
||||
|
||||
async after(): Promise<void> {
|
||||
this.calls.push(`${this.label}.after`);
|
||||
}
|
||||
}
|
||||
|
||||
interface CombatFixture {
|
||||
/** 被测战斗流程对象 */
|
||||
flow: InstanceType<TestModules['CombatFlow']>;
|
||||
/** 数据层状态假对象 */
|
||||
state: IStateBase;
|
||||
/** 怪物上下文假对象 */
|
||||
context: IEnemyContext<TestEnemyAttr, TestHeroAttr>;
|
||||
/** 伤害上下文假对象 */
|
||||
damage: IDamageContext<TestEnemyAttr, TestHeroAttr>;
|
||||
/** 可修改勇士属性 */
|
||||
hero: InstanceType<TestModules['HeroAttribute']>;
|
||||
/** 怪物视图假对象 */
|
||||
view: IEnemyView<TestEnemyAttr>;
|
||||
/** 计算后怪物 */
|
||||
computed: IReadonlyEnemy<TestEnemyAttr>;
|
||||
/** 可修改原始怪物 */
|
||||
origin: IEnemy<TestEnemyAttr>;
|
||||
/** 怪物位置 */
|
||||
locator: ITileLocator;
|
||||
/** 共享调用顺序记录 */
|
||||
calls: string[];
|
||||
/** 伤害上下文固定返回的信息对象 */
|
||||
info: IEnemyDamageInfo<TestEnemyAttr, TestHeroAttr>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建战斗流程测试夹具,怪物/伤害上下文只实现被测路径所需的最小接口
|
||||
*/
|
||||
function createFixture(): CombatFixture {
|
||||
const state = {} as IStateBase;
|
||||
const calls: string[] = [];
|
||||
const origin = new modules.Enemy<TestEnemyAttr>('enemy-1', 1, {
|
||||
hp: 10,
|
||||
atk: 2,
|
||||
def: 0
|
||||
});
|
||||
const computed = origin.clone();
|
||||
const locator: ITileLocator = { x: 1, y: 1 };
|
||||
const view: IEnemyView<TestEnemyAttr> = {
|
||||
context: {} as never,
|
||||
reset: () => {},
|
||||
getBaseEnemy: () => origin,
|
||||
getComputedEnemy: () => computed,
|
||||
getModifiableEnemy: () => origin,
|
||||
markDirty: () => {}
|
||||
};
|
||||
const hero = new modules.HeroAttribute<TestHeroAttr>({
|
||||
hp: 100,
|
||||
atk: 0,
|
||||
def: 0
|
||||
});
|
||||
const context = {
|
||||
state,
|
||||
width: 2,
|
||||
height: 2,
|
||||
getEnemyLocatorByView: () => locator,
|
||||
getEnemyLocator: () => locator,
|
||||
getViewByComputed: () => view,
|
||||
getEnemyByLocator: () => view,
|
||||
getEnemyByLoc: () => view
|
||||
} as never;
|
||||
const info = { handler: {} as never, damage: 5, turn: 2 } as never;
|
||||
const damage = {
|
||||
state,
|
||||
getDamageInfoByHandler: () => info
|
||||
} as never;
|
||||
const flow = new modules.CombatFlow<TestEnemyAttr, TestHeroAttr>(state);
|
||||
return {
|
||||
flow,
|
||||
state,
|
||||
context,
|
||||
damage,
|
||||
hero,
|
||||
view,
|
||||
computed,
|
||||
origin,
|
||||
locator,
|
||||
calls,
|
||||
info
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 把夹具的怪物上下文、伤害上下文与勇士全部绑定到战斗流程上
|
||||
*/
|
||||
function bindAll(fixture: CombatFixture): void {
|
||||
fixture.flow.bindContext(fixture.context);
|
||||
fixture.flow.bindDamage(fixture.damage);
|
||||
fixture.flow.bindHero(fixture.hero);
|
||||
}
|
||||
|
||||
describe('CombatFlow binding', () => {
|
||||
// 验证同 state 的怪物与伤害上下文可绑定,传入 null 会清空绑定
|
||||
it('binds same-state collaborators and clears them with null', () => {
|
||||
const fixture = createFixture();
|
||||
|
||||
fixture.flow.bindContext(fixture.context);
|
||||
fixture.flow.bindDamage(fixture.damage);
|
||||
expect(fixture.flow.context).toBe(fixture.context);
|
||||
expect(fixture.flow.damage).toBe(fixture.damage);
|
||||
|
||||
fixture.flow.bindContext(null);
|
||||
fixture.flow.bindDamage(null);
|
||||
expect(fixture.flow.context).toBeNull();
|
||||
expect(fixture.flow.damage).toBeNull();
|
||||
});
|
||||
|
||||
// 验证绑定外来 state 的上下文或伤害对象时告警 138 且保持未绑定
|
||||
it('warns 138 for collaborators bound to a foreign state', () => {
|
||||
const fixture = createFixture();
|
||||
const foreignState = {} as IStateBase;
|
||||
const foreignContext = { state: foreignState } as never;
|
||||
const foreignDamage = { state: foreignState } as never;
|
||||
|
||||
const contextResult = modules.logger.catch(() =>
|
||||
fixture.flow.bindContext(foreignContext)
|
||||
);
|
||||
const damageResult = modules.logger.catch(() =>
|
||||
fixture.flow.bindDamage(foreignDamage)
|
||||
);
|
||||
|
||||
expect(fixture.flow.context).toBeNull();
|
||||
expect(fixture.flow.damage).toBeNull();
|
||||
expect(contextResult.info.map(v => v.code)).toContain(138);
|
||||
expect(damageResult.info.map(v => v.code)).toContain(138);
|
||||
});
|
||||
|
||||
// 验证绑定与清空勇士对象会同步更新 hero 成员
|
||||
it('binds and clears the hero reference', () => {
|
||||
const fixture = createFixture();
|
||||
|
||||
fixture.flow.bindHero(fixture.hero);
|
||||
expect(fixture.flow.hero).toBe(fixture.hero);
|
||||
|
||||
fixture.flow.bindHero(null);
|
||||
expect(fixture.flow.hero).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('CombatFlow scripts and guards', () => {
|
||||
// 验证脚本按优先级降序执行,重复优先级告警 140 且不加入列表
|
||||
it('sorts scripts by descending priority and rejects duplicates', async () => {
|
||||
const fixture = createFixture();
|
||||
bindAll(fixture);
|
||||
const low = new FakeScript(1, 'low', fixture.calls);
|
||||
const high = new FakeScript(2, 'high', fixture.calls);
|
||||
const duplicate = new FakeScript(1, 'duplicate', fixture.calls);
|
||||
fixture.flow.addCombatScript(low);
|
||||
fixture.flow.addCombatScript(high);
|
||||
|
||||
const result = modules.logger.catch(() =>
|
||||
fixture.flow.addCombatScript(duplicate)
|
||||
);
|
||||
|
||||
expect(result.info.map(v => v.code)).toContain(140);
|
||||
|
||||
await fixture.flow.battle(fixture.view);
|
||||
expect(fixture.calls).toEqual([
|
||||
'high.before',
|
||||
'low.before',
|
||||
'high.after',
|
||||
'low.after'
|
||||
]);
|
||||
});
|
||||
|
||||
// 验证缺少怪物上下文、勇士或伤害上下文时告警 139 并返回 null
|
||||
it('warns 139 when a required collaborator is missing', async () => {
|
||||
const noContext = createFixture();
|
||||
const contextResult = modules.logger.catch(() =>
|
||||
noContext.flow.battle(noContext.view)
|
||||
);
|
||||
expect(contextResult.info.map(v => v.code)).toContain(139);
|
||||
await expect(contextResult.ret).resolves.toBeNull();
|
||||
|
||||
const noHero = createFixture();
|
||||
noHero.flow.bindContext(noHero.context);
|
||||
const heroResult = modules.logger.catch(() =>
|
||||
noHero.flow.battle(noHero.view)
|
||||
);
|
||||
expect(heroResult.info.map(v => v.code)).toContain(139);
|
||||
await expect(heroResult.ret).resolves.toBeNull();
|
||||
|
||||
const noDamage = createFixture();
|
||||
noDamage.flow.bindContext(noDamage.context);
|
||||
noDamage.flow.bindHero(noDamage.hero);
|
||||
const damageResult = modules.logger.catch(() =>
|
||||
noDamage.flow.battle(noDamage.view)
|
||||
);
|
||||
expect(damageResult.info.map(v => v.code)).toContain(139);
|
||||
await expect(damageResult.ret).resolves.toBeNull();
|
||||
});
|
||||
|
||||
// 验证伤害上下文无法产出伤害信息时告警 141 并返回 null
|
||||
it('warns 141 when the damage context cannot produce damage info', async () => {
|
||||
const fixture = createFixture();
|
||||
fixture.flow.bindContext(fixture.context);
|
||||
fixture.flow.bindHero(fixture.hero);
|
||||
fixture.flow.bindDamage({
|
||||
state: fixture.state,
|
||||
getDamageInfoByHandler: () => null
|
||||
} as never);
|
||||
|
||||
const result = modules.logger.catch(() =>
|
||||
fixture.flow.battle(fixture.view)
|
||||
);
|
||||
|
||||
expect(result.info.map(v => v.code)).toContain(141);
|
||||
await expect(result.ret).resolves.toBeNull();
|
||||
});
|
||||
|
||||
// 验证已知计算后怪物会转交给对应视图的 battle 流程
|
||||
it('delegates a known computed enemy to the view battle flow', async () => {
|
||||
const fixture = createFixture();
|
||||
bindAll(fixture);
|
||||
|
||||
const info = await fixture.flow.battleComputed(fixture.computed);
|
||||
|
||||
expect(info).toBe(fixture.info);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CombatFlow async ordering', () => {
|
||||
// 验证战前脚本 await 完成后才依次执行战前钩子、战后脚本与战后钩子
|
||||
it('awaits the before script before running hooks and after scripts', async () => {
|
||||
const fixture = createFixture();
|
||||
bindAll(fixture);
|
||||
const gate = createDeferred();
|
||||
const script = new FakeScript(1, 'script', fixture.calls);
|
||||
script.gate = gate;
|
||||
fixture.flow.addCombatScript(script);
|
||||
fixture.flow
|
||||
.addHook({
|
||||
onBeforeCombat: async () => {
|
||||
fixture.calls.push('hooks.onBeforeCombat');
|
||||
},
|
||||
onAfterCombat: async () => {
|
||||
fixture.calls.push('hooks.onAfterCombat');
|
||||
}
|
||||
})
|
||||
.load();
|
||||
|
||||
const running = fixture.flow.battle(fixture.view);
|
||||
await Promise.resolve();
|
||||
expect(fixture.calls).toEqual(['script.before']);
|
||||
|
||||
gate.resolve();
|
||||
await running;
|
||||
|
||||
expect(fixture.calls).toEqual([
|
||||
'script.before',
|
||||
'hooks.onBeforeCombat',
|
||||
'script.after',
|
||||
'hooks.onAfterCombat'
|
||||
]);
|
||||
});
|
||||
|
||||
// 验证战前脚本返回真值时短路,不再执行钩子与战后脚本
|
||||
it('short-circuits hooks and after scripts when before returns truthy', async () => {
|
||||
const fixture = createFixture();
|
||||
bindAll(fixture);
|
||||
const script = new FakeScript(1, 'script', fixture.calls, true);
|
||||
fixture.flow.addCombatScript(script);
|
||||
fixture.flow
|
||||
.addHook({
|
||||
onBeforeCombat: async () => {
|
||||
fixture.calls.push('hooks.onBeforeCombat');
|
||||
},
|
||||
onAfterCombat: async () => {
|
||||
fixture.calls.push('hooks.onAfterCombat');
|
||||
}
|
||||
})
|
||||
.load();
|
||||
|
||||
const info = await fixture.flow.battle(fixture.view);
|
||||
|
||||
expect(info).toBe(fixture.info);
|
||||
expect(fixture.calls).toEqual(['script.before']);
|
||||
});
|
||||
|
||||
// 疑似 bug:接口约定战前脚本返回 false 应放弃战斗,详见 06-TEST-FINDINGS.md #06-01-3,修复后取消 skip
|
||||
it.skip('abandons the battle when the before script returns false', async () => {
|
||||
const fixture = createFixture();
|
||||
bindAll(fixture);
|
||||
const script = new FakeScript(1, 'script', fixture.calls, false);
|
||||
fixture.flow.addCombatScript(script);
|
||||
fixture.flow
|
||||
.addHook({
|
||||
onBeforeCombat: async () => {
|
||||
fixture.calls.push('hooks.onBeforeCombat');
|
||||
}
|
||||
})
|
||||
.load();
|
||||
|
||||
await fixture.flow.battle(fixture.view);
|
||||
|
||||
expect(fixture.calls).toEqual(['script.before']);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user