mirror of
https://github.com/motajs/template.git
synced 2026-09-26 04:30:16 +08:00
test(06-05): cover hero assembly and item/equipment pipeline (stage 2)
- add location/state tests: locator, floor/pos hooks, subsystem assembly, code 116, changeFloor hook order - add equipment/equipStore/items tests: equip/unequip/replace, compareEquip, instance sorting, item routing, code 146 - add follower tests: add/remove hooks, neighbour links, sync/async gather, code 142 - record suspected bugs #06-05-2/#06-05-3 (named-slot empty check, unreachable code 147) as it.skip
This commit is contained in:
parent
c1781b8102
commit
6d5081abcb
237
packages-user/data-base/src/hero/equipStore.test.ts
Normal file
237
packages-user/data-base/src/hero/equipStore.test.ts
Normal file
@ -0,0 +1,237 @@
|
||||
// 测试 HeroEquipsStore 构件:实例增删计数、按 uid/排序器排序与 EquipmentState 修饰器生成
|
||||
import { afterAll, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
type IDataCommon,
|
||||
type IHeroAttr,
|
||||
type IItemRawData,
|
||||
ItemCategory,
|
||||
ItemStore,
|
||||
TileStore,
|
||||
TileType
|
||||
} from '@user/data-common';
|
||||
import { HeroEquipsStore } from './equipStore';
|
||||
import { PercentageModifier, ValueModifier } from './modifier';
|
||||
import { type IEquipmentSortHandler, type IEquipmentSorter } from './types';
|
||||
|
||||
vi.hoisted(() => {
|
||||
vi.stubGlobal('main', { replayChecking: true });
|
||||
vi.stubGlobal('location', { origin: 'http://localhost' });
|
||||
Map.prototype.getOrInsert ??= function <K, V>(
|
||||
this: Map<K, V>,
|
||||
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 <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;
|
||||
};
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
type HeroKey = SelectKey<IHeroAttr, number>;
|
||||
|
||||
interface TestEnv {
|
||||
state: IDataCommon;
|
||||
tileStore: IDataCommon['tileStore'];
|
||||
itemStore: IDataCommon['itemStore'];
|
||||
store: HeroEquipsStore<IHeroAttr>;
|
||||
}
|
||||
|
||||
/** 构造一个仅含图块与道具存储的公共层假对象 */
|
||||
function createState(): IDataCommon {
|
||||
return {
|
||||
tileStore: new TileStore(),
|
||||
itemStore: new ItemStore()
|
||||
} as never;
|
||||
}
|
||||
|
||||
/** 构造一个装配装备实例存储的测试环境 */
|
||||
function createEnv(): TestEnv {
|
||||
const state = createState();
|
||||
return {
|
||||
state,
|
||||
tileStore: state.tileStore,
|
||||
itemStore: state.itemStore,
|
||||
store: new HeroEquipsStore<IHeroAttr>(state)
|
||||
};
|
||||
}
|
||||
|
||||
/** 构造一个带合成装备属性的道具定义 */
|
||||
function createItem(
|
||||
num: number,
|
||||
id: string,
|
||||
value: [HeroKey, number][] = [],
|
||||
percentage: [HeroKey, number][] = []
|
||||
): IItemRawData<IHeroAttr> {
|
||||
return {
|
||||
num,
|
||||
id,
|
||||
category: ItemCategory.Equipment,
|
||||
name: id,
|
||||
text: id,
|
||||
hideInToolbox: false,
|
||||
effect: { useEvent: null, useEffect: () => {}, canUse: () => false },
|
||||
equip: {
|
||||
slots: [0],
|
||||
animate: 'sword',
|
||||
value: new Map(value),
|
||||
percentage: new Map(percentage),
|
||||
loadEvent: null,
|
||||
unloadEvent: null
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** 向图块与道具存储注册一个装备道具定义 */
|
||||
function registerItem(env: TestEnv, item: IItemRawData<IHeroAttr>): void {
|
||||
env.tileStore.addTile({
|
||||
num: item.num,
|
||||
id: item.id,
|
||||
events: {},
|
||||
type: TileType.Item,
|
||||
pass: { onlyEvents: false, inPass: 15, outPass: 15 },
|
||||
eventPass: true
|
||||
});
|
||||
env.itemStore.addItem(item);
|
||||
}
|
||||
|
||||
/** 一个按 uid 降序排列的测试排序器 */
|
||||
class FakeSorter implements IEquipmentSorter<IHeroAttr> {
|
||||
compare(handler: IEquipmentSortHandler<IHeroAttr>): number {
|
||||
return handler.equipB.uid - handler.equipA.uid;
|
||||
}
|
||||
}
|
||||
|
||||
describe('HeroEquipsStore instances', () => {
|
||||
// 验证 add 分配自增 uid,未知图块或缺失定义返回 -1
|
||||
it('allocates increasing uids and rejects unknown items', () => {
|
||||
const env = createEnv();
|
||||
registerItem(env, createItem(10, 'sword'));
|
||||
env.tileStore.addTile({
|
||||
num: 12,
|
||||
id: 'ghost',
|
||||
events: {},
|
||||
type: TileType.Item,
|
||||
pass: { onlyEvents: false, inPass: 15, outPass: 15 },
|
||||
eventPass: true
|
||||
});
|
||||
|
||||
expect(env.store.add(10)).toBe(0);
|
||||
expect(env.store.add('sword')).toBe(1);
|
||||
expect(env.store.add(99)).toBe(-1);
|
||||
expect(env.store.add('missing')).toBe(-1);
|
||||
expect(env.store.add(12)).toBe(-1);
|
||||
});
|
||||
|
||||
// 验证 get/count/delete 按 uid 或图块查询并计数
|
||||
it('gets, counts and deletes instances', () => {
|
||||
const env = createEnv();
|
||||
registerItem(env, createItem(10, 'sword'));
|
||||
const first = env.store.add(10);
|
||||
env.store.add(10);
|
||||
|
||||
expect(env.store.get(first)?.uid).toBe(first);
|
||||
expect(env.store.get(99)).toBeNull();
|
||||
expect(env.store.count(10)).toBe(2);
|
||||
expect(env.store.count('sword')).toBe(2);
|
||||
expect(env.store.count(99)).toBe(0);
|
||||
|
||||
env.store.delete(first);
|
||||
expect(env.store.get(first)).toBeNull();
|
||||
expect(env.store.count(10)).toBe(1);
|
||||
});
|
||||
|
||||
// 验证 instancesOf 只输出指定图块实例,未知图块返回空数组
|
||||
it('lists instances filtered by item', () => {
|
||||
const env = createEnv();
|
||||
registerItem(env, createItem(10, 'sword'));
|
||||
registerItem(env, createItem(11, 'axe'));
|
||||
const swordA = env.store.add(10);
|
||||
const axe = env.store.add(11);
|
||||
const swordB = env.store.add(10);
|
||||
|
||||
expect(env.store.instancesOf(10).map(state => state.uid)).toEqual([
|
||||
swordA,
|
||||
swordB
|
||||
]);
|
||||
expect(env.store.instancesOf('axe').map(state => state.uid)).toEqual([
|
||||
axe
|
||||
]);
|
||||
expect(env.store.instancesOf(99)).toEqual([]);
|
||||
});
|
||||
|
||||
// 验证无排序器时按 uid 升序,使用排序器后按其顺序且并列回退 uid
|
||||
it('orders instances by uid by default and by the sorter when set', () => {
|
||||
const env = createEnv();
|
||||
registerItem(env, createItem(10, 'sword'));
|
||||
const first = env.store.add(10);
|
||||
const second = env.store.add(10);
|
||||
const third = env.store.add(10);
|
||||
|
||||
expect(env.store.instances().map(state => state.uid)).toEqual([
|
||||
first,
|
||||
second,
|
||||
third
|
||||
]);
|
||||
|
||||
env.store.useSorter(new FakeSorter());
|
||||
expect(env.store.instances().map(state => state.uid)).toEqual([
|
||||
third,
|
||||
second,
|
||||
first
|
||||
]);
|
||||
expect(env.store.instancesOf(10).map(state => state.uid)).toEqual([
|
||||
third,
|
||||
second,
|
||||
first
|
||||
]);
|
||||
|
||||
env.store.useSorter({ compare: () => 0 });
|
||||
expect(env.store.instances().map(state => state.uid)).toEqual([
|
||||
first,
|
||||
second,
|
||||
third
|
||||
]);
|
||||
|
||||
env.store.useSorter(null);
|
||||
expect(env.store.instances().map(state => state.uid)).toEqual([
|
||||
first,
|
||||
second,
|
||||
third
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('EquipmentState modifiers', () => {
|
||||
// 验证装备实例按定义生成数值与百分比修饰器
|
||||
it('builds value and percentage modifiers from the item', () => {
|
||||
const env = createEnv();
|
||||
const item = createItem(10, 'sword', [['atk', 5]], [['hp', 0.2]]);
|
||||
registerItem(env, item);
|
||||
const uid = env.store.add(10);
|
||||
const state = env.store.get(uid)!;
|
||||
|
||||
const modifiers = [...state.getModifiers()];
|
||||
|
||||
expect(state.uid).toBe(uid);
|
||||
expect(state.item).toBe(item);
|
||||
expect(modifiers.map(item => item[0])).toEqual(['atk', 'hp']);
|
||||
expect(modifiers[0][1]).toBeInstanceOf(ValueModifier);
|
||||
expect(modifiers[1][1]).toBeInstanceOf(PercentageModifier);
|
||||
});
|
||||
});
|
||||
334
packages-user/data-base/src/hero/equipment.test.ts
Normal file
334
packages-user/data-base/src/hero/equipment.test.ts
Normal file
@ -0,0 +1,334 @@
|
||||
// 测试 HeroEquipment 组合行为:槽位判定、装备/替换/卸下、属性修饰器联动、compareEquip 与码 146/147
|
||||
import { afterAll, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
type IDataCommon,
|
||||
type IHeroAttr,
|
||||
type IItemRawData,
|
||||
ItemCategory,
|
||||
ItemStore,
|
||||
TileStore,
|
||||
TileType
|
||||
} from '@user/data-common';
|
||||
import { logger } from '@motajs/common';
|
||||
import { HeroAttribute } from './attribute';
|
||||
import { HeroEquipsStore } from './equipStore';
|
||||
import { HeroEquipment } from './equipment';
|
||||
import { EquipStatus } from './types';
|
||||
|
||||
vi.hoisted(() => {
|
||||
vi.stubGlobal('main', { replayChecking: true });
|
||||
vi.stubGlobal('location', { origin: 'http://localhost' });
|
||||
Map.prototype.getOrInsert ??= function <K, V>(
|
||||
this: Map<K, V>,
|
||||
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 <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;
|
||||
};
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
type HeroKey = SelectKey<IHeroAttr, number>;
|
||||
|
||||
interface TestEnv {
|
||||
state: IDataCommon;
|
||||
tileStore: IDataCommon['tileStore'];
|
||||
itemStore: IDataCommon['itemStore'];
|
||||
store: HeroEquipsStore<IHeroAttr>;
|
||||
equipment: HeroEquipment<IHeroAttr>;
|
||||
attribute: HeroAttribute<IHeroAttr>;
|
||||
}
|
||||
|
||||
/** 构造一份合成的勇士基础属性 */
|
||||
function createBaseAttr(): IHeroAttr {
|
||||
return {
|
||||
name: 'hero',
|
||||
hp: 100,
|
||||
hpmax: 100,
|
||||
atk: 10,
|
||||
def: 5,
|
||||
mdef: 0,
|
||||
mana: 0,
|
||||
manamax: 0,
|
||||
money: 0,
|
||||
exp: 0
|
||||
};
|
||||
}
|
||||
|
||||
/** 构造一个带合成装备属性与修饰器的装备道具定义 */
|
||||
function createItem(
|
||||
num: number,
|
||||
id: string,
|
||||
slots: (number | string)[] = [0],
|
||||
value: [HeroKey, number][] = [],
|
||||
percentage: [HeroKey, number][] = []
|
||||
): IItemRawData<IHeroAttr> {
|
||||
return {
|
||||
num,
|
||||
id,
|
||||
category: ItemCategory.Equipment,
|
||||
name: id,
|
||||
text: id,
|
||||
hideInToolbox: false,
|
||||
effect: { useEvent: null, useEffect: () => {}, canUse: () => false },
|
||||
equip: {
|
||||
slots,
|
||||
animate: 'sword',
|
||||
value: new Map(value),
|
||||
percentage: new Map(percentage),
|
||||
loadEvent: null,
|
||||
unloadEvent: null
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** 构造一个装配勇士装备对象的测试环境 */
|
||||
function createEnv(): TestEnv {
|
||||
const tileStore = new TileStore();
|
||||
const itemStore = new ItemStore<IHeroAttr, unknown>();
|
||||
const state = { tileStore, itemStore } as never;
|
||||
const attribute = new HeroAttribute<IHeroAttr>(createBaseAttr());
|
||||
const store = new HeroEquipsStore<IHeroAttr>(state);
|
||||
const equipment = new HeroEquipment<IHeroAttr>(store, attribute);
|
||||
return { state, tileStore, itemStore, store, equipment, attribute };
|
||||
}
|
||||
|
||||
/** 向图块与道具存储注册一个装备道具定义 */
|
||||
function registerItem(env: TestEnv, item: IItemRawData<IHeroAttr>): void {
|
||||
env.tileStore.addTile({
|
||||
num: item.num,
|
||||
id: item.id,
|
||||
events: {},
|
||||
type: TileType.Item,
|
||||
pass: { onlyEvents: false, inPass: 15, outPass: 15 },
|
||||
eventPass: true
|
||||
});
|
||||
env.itemStore.addItem(item);
|
||||
}
|
||||
|
||||
describe('HeroEquipment slots', () => {
|
||||
// 验证装备槽名称可设置与读取
|
||||
it('sets equipment slot names', () => {
|
||||
const env = createEnv();
|
||||
|
||||
env.equipment.setSlots(['weapon', 'armor']);
|
||||
|
||||
expect(env.equipment.slots).toEqual(['weapon', 'armor']);
|
||||
});
|
||||
|
||||
// 验证数值槽与名称槽的三种装备判定结果
|
||||
it('classifies equip status for numeric and named slots', () => {
|
||||
const env = createEnv();
|
||||
registerItem(env, createItem(10, 'sword', [0, 'weapon'], [['atk', 5]]));
|
||||
env.equipment.setSlots(['weapon']);
|
||||
const uid = env.store.add(10);
|
||||
|
||||
expect(env.equipment.canEquipTo(uid, 0)).toBe(EquipStatus.CanEquip);
|
||||
expect(env.equipment.canEquipTo(uid, 'weapon')).toBe(
|
||||
EquipStatus.CanEquip
|
||||
);
|
||||
expect(env.equipment.canEquipTo(uid, 5)).toBe(EquipStatus.CannotEquip);
|
||||
expect(env.equipment.canEquipTo(999, 0)).toBe(EquipStatus.CannotEquip);
|
||||
|
||||
env.equipment.equip(uid, 0);
|
||||
|
||||
expect(env.equipment.canEquipTo(uid, 0)).toBe(EquipStatus.NeedReplace);
|
||||
expect(env.equipment.canEquipTo(uid, 'weapon')).toBe(
|
||||
EquipStatus.NeedReplace
|
||||
);
|
||||
|
||||
registerItem(env, createItem(11, 'armor', ['armor'], [['def', 2]]));
|
||||
const armorUid = env.store.add(11);
|
||||
expect(env.equipment.canEquipTo(armorUid, 'armor')).toBe(
|
||||
EquipStatus.CannotEquip
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('HeroEquipment equip and unequip', () => {
|
||||
// 验证装备到空槽返回 undefined 并应用数值修饰器
|
||||
it('equips into an empty slot and applies modifiers', () => {
|
||||
const env = createEnv();
|
||||
registerItem(env, createItem(10, 'sword', [0], [['atk', 5]]));
|
||||
env.equipment.setSlots(['weapon']);
|
||||
const uid = env.store.add(10);
|
||||
|
||||
expect(env.equipment.equip(uid, 0)).toBeUndefined();
|
||||
expect(env.equipment.getEquipped(0)).toBe(uid);
|
||||
expect(env.equipment.equipped(uid)).toBe(true);
|
||||
expect(env.attribute.getFinalAttribute('atk')).toBe(15);
|
||||
});
|
||||
|
||||
// 验证替换占用槽返回旧 uid 并换上新装备的修饰器
|
||||
it('replaces the occupant and swaps its modifiers', () => {
|
||||
const env = createEnv();
|
||||
registerItem(env, createItem(10, 'sword', [0], [['atk', 5]]));
|
||||
registerItem(env, createItem(11, 'axe', [0], [['atk', 12]]));
|
||||
env.equipment.setSlots(['weapon']);
|
||||
const sword = env.store.add(10);
|
||||
const axe = env.store.add(11);
|
||||
|
||||
env.equipment.equip(sword, 0);
|
||||
expect(env.equipment.equip(axe, 0)).toBe(sword);
|
||||
expect(env.equipment.getEquipped(0)).toBe(axe);
|
||||
expect(env.equipment.equipped(sword)).toBe(false);
|
||||
expect(env.attribute.getFinalAttribute('atk')).toBe(22);
|
||||
});
|
||||
|
||||
// 验证同一件装备在启用自动卸下时移动到新的名称槽
|
||||
it('moves an already equipped item when autoUnload is enabled', () => {
|
||||
const env = createEnv();
|
||||
registerItem(
|
||||
env,
|
||||
createItem(10, 'sword', ['weapon', 'armor'], [['atk', 5]])
|
||||
);
|
||||
env.equipment.setSlots(['weapon', 'armor']);
|
||||
const uid = env.store.add(10);
|
||||
|
||||
env.equipment.equip(uid, 'weapon');
|
||||
expect(env.equipment.equip(uid, 'armor')).toBeUndefined();
|
||||
expect(env.equipment.getEquipped(0)).toBeUndefined();
|
||||
expect(env.equipment.getEquipped(1)).toBe(uid);
|
||||
});
|
||||
|
||||
// 验证关闭自动卸下或重复装备同一槽位时保持原状
|
||||
it('keeps the previous slot when autoUnload is disabled', () => {
|
||||
const env = createEnv();
|
||||
registerItem(
|
||||
env,
|
||||
createItem(10, 'sword', ['weapon', 'armor'], [['atk', 5]])
|
||||
);
|
||||
env.equipment.setSlots(['weapon', 'armor']);
|
||||
const uid = env.store.add(10);
|
||||
|
||||
env.equipment.equip(uid, 'weapon');
|
||||
expect(env.equipment.equip(uid, 'armor', false)).toBeUndefined();
|
||||
expect(env.equipment.getEquipped(0)).toBe(uid);
|
||||
expect(env.equipment.getEquipped(1)).toBeUndefined();
|
||||
|
||||
expect(env.equipment.equip(uid, 'weapon')).toBeUndefined();
|
||||
expect(env.equipment.getEquipped(0)).toBe(uid);
|
||||
});
|
||||
|
||||
// 验证卸下返回被移除的 uid 并移除修饰器,空槽返回 undefined
|
||||
it('unequips a slot and removes its modifiers', () => {
|
||||
const env = createEnv();
|
||||
registerItem(env, createItem(10, 'sword', [0], [['atk', 5]]));
|
||||
env.equipment.setSlots(['weapon']);
|
||||
const uid = env.store.add(10);
|
||||
env.equipment.equip(uid, 0);
|
||||
|
||||
expect(env.equipment.unequip(0)).toBe(uid);
|
||||
expect(env.equipment.getEquipped(0)).toBeUndefined();
|
||||
expect(env.equipment.equipped(uid)).toBe(false);
|
||||
expect(env.attribute.getFinalAttribute('atk')).toBe(10);
|
||||
expect(env.equipment.unequip(0)).toBeUndefined();
|
||||
});
|
||||
|
||||
// 验证 getEquips 按槽位顺序输出装备状态或空位
|
||||
it('lists slots in order with their equipment states', () => {
|
||||
const env = createEnv();
|
||||
registerItem(env, createItem(10, 'sword', [0, 1], [['atk', 5]]));
|
||||
env.equipment.setSlots(['weapon', 'armor']);
|
||||
const uid = env.store.add(10);
|
||||
env.equipment.equip(uid, 0);
|
||||
|
||||
const equips = env.equipment.getEquips();
|
||||
|
||||
expect(equips).toHaveLength(2);
|
||||
expect(equips[0]).toBe(env.store.get(uid));
|
||||
expect(equips[1]).toBeNull();
|
||||
});
|
||||
|
||||
// 疑似 bug:字符串槽位空槽判断条件写反导致总是替换首个匹配槽位,详见 06-TEST-FINDINGS.md #06-05-2
|
||||
it.skip('uses the first empty named slot instead of replacing an occupant', () => {
|
||||
const env = createEnv();
|
||||
registerItem(env, createItem(10, 'sword', ['weapon'], [['atk', 5]]));
|
||||
registerItem(env, createItem(11, 'axe', ['weapon'], [['atk', 12]]));
|
||||
env.equipment.setSlots(['weapon', 'weapon']);
|
||||
const sword = env.store.add(10);
|
||||
const axe = env.store.add(11);
|
||||
|
||||
env.equipment.equip(sword, 'weapon');
|
||||
env.equipment.equip(axe, 'weapon');
|
||||
|
||||
expect(env.equipment.getEquipped(0)).toBe(sword);
|
||||
expect(env.equipment.getEquipped(1)).toBe(axe);
|
||||
});
|
||||
|
||||
// 疑似 bug:码 147 因 canEquipTo 先返回 CannotEquip 而不可达,详见 06-TEST-FINDINGS.md #06-05-3
|
||||
it.skip('warns code 147 when no equipment slot is available', () => {
|
||||
const env = createEnv();
|
||||
registerItem(env, createItem(10, 'sword', ['weapon'], [['atk', 5]]));
|
||||
env.equipment.setSlots([]);
|
||||
const uid = env.store.add(10);
|
||||
|
||||
const result = logger.catch(() => env.equipment.equip(uid, 'weapon'));
|
||||
|
||||
expect(result.info.map(info => info.code)).toContain(147);
|
||||
});
|
||||
});
|
||||
|
||||
describe('HeroEquipment compare and guards', () => {
|
||||
// 验证 compareEquip 逐属性输出两个装备的最终属性差
|
||||
it('diffs the final attributes of two equipment instances', () => {
|
||||
const env = createEnv();
|
||||
registerItem(env, createItem(10, 'sword', [0], [['atk', 5]]));
|
||||
registerItem(env, createItem(11, 'axe', [0], [['def', 3]]));
|
||||
env.equipment.setSlots(['weapon']);
|
||||
const sword = env.store.add(10);
|
||||
const axe = env.store.add(11);
|
||||
|
||||
const diff = env.equipment.compareEquip(sword, axe, 0);
|
||||
|
||||
expect(Object.keys(diff).sort()).toEqual(['atk', 'def']);
|
||||
expect(diff.atk).toBe(5);
|
||||
expect(diff.def).toBe(-3);
|
||||
});
|
||||
|
||||
// 验证装备实例缺失时卸下告警 146
|
||||
it('warns code 146 when the equipped instance is missing', () => {
|
||||
const env = createEnv();
|
||||
registerItem(env, createItem(10, 'sword', [0], [['atk', 5]]));
|
||||
env.equipment.setSlots(['weapon']);
|
||||
const uid = env.store.add(10);
|
||||
env.equipment.equip(uid, 0);
|
||||
env.store.delete(uid);
|
||||
|
||||
const result = logger.catch(() => env.equipment.unequip(0));
|
||||
|
||||
expect(result.ret).toBeUndefined();
|
||||
expect(result.info.map(info => info.code)).toContain(146);
|
||||
});
|
||||
|
||||
// 验证比较未知 uid 时告警 146 并返回空差异对象
|
||||
it('warns code 146 when comparing an unknown uid', () => {
|
||||
const env = createEnv();
|
||||
registerItem(env, createItem(10, 'sword', [0], [['atk', 5]]));
|
||||
const uid = env.store.add(10);
|
||||
|
||||
const result = logger.catch(() =>
|
||||
env.equipment.compareEquip(uid, 999, 0)
|
||||
);
|
||||
|
||||
expect(result.ret).toEqual({});
|
||||
expect(result.info.map(info => info.code)).toContain(146);
|
||||
});
|
||||
});
|
||||
266
packages-user/data-base/src/hero/follower.test.ts
Normal file
266
packages-user/data-base/src/hero/follower.test.ts
Normal file
@ -0,0 +1,266 @@
|
||||
// 测试 followers 组合行为:增删与钩子、邻居链接、同步/异步聚集与码 142
|
||||
import { afterAll, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
type IDataCommon,
|
||||
Dir8FaceHandler,
|
||||
FaceDirection,
|
||||
ItemStore,
|
||||
TileStore,
|
||||
TileType
|
||||
} from '@user/data-common';
|
||||
import { logger } from '@motajs/common';
|
||||
import { type IPassPredicate } from '../map';
|
||||
import { HeroFollowersController } from './follower';
|
||||
import { HeroLocation } from './location';
|
||||
import { type IHeroMoveTopImpl } from './types';
|
||||
|
||||
vi.hoisted(() => {
|
||||
vi.stubGlobal('main', { replayChecking: true });
|
||||
vi.stubGlobal('location', { origin: 'http://localhost' });
|
||||
Map.prototype.getOrInsert ??= function <K, V>(
|
||||
this: Map<K, V>,
|
||||
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 <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;
|
||||
};
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
interface TestEnv {
|
||||
state: IDataCommon;
|
||||
location: HeroLocation;
|
||||
controller: HeroFollowersController;
|
||||
}
|
||||
|
||||
/** 构造一个注册了跟随者图块的公共层假对象 */
|
||||
function createState(): IDataCommon {
|
||||
const tileStore = new TileStore();
|
||||
const itemStore = new ItemStore();
|
||||
tileStore.addTile({
|
||||
num: 100,
|
||||
id: 'ghost',
|
||||
events: {},
|
||||
type: TileType.Npc,
|
||||
pass: { onlyEvents: false, inPass: 15, outPass: 15 },
|
||||
eventPass: true
|
||||
});
|
||||
return { tileStore, itemStore } as never;
|
||||
}
|
||||
|
||||
/** 构造一个停在原点的勇士位置对象 */
|
||||
function createLocation(
|
||||
state: IDataCommon,
|
||||
faceHandler: Dir8FaceHandler
|
||||
): HeroLocation {
|
||||
return new HeroLocation(
|
||||
state,
|
||||
{ x: 0, y: 0, direction: FaceDirection.Down },
|
||||
faceHandler
|
||||
);
|
||||
}
|
||||
|
||||
/** 构造一个跟随者控制器及其勇士位置对象 */
|
||||
function createController(): TestEnv {
|
||||
const state = createState();
|
||||
const faceHandler = new Dir8FaceHandler();
|
||||
const location = createLocation(state, faceHandler);
|
||||
return {
|
||||
state,
|
||||
location,
|
||||
controller: new HeroFollowersController(state, location, faceHandler)
|
||||
};
|
||||
}
|
||||
|
||||
/** 一个始终允许通行的顶层移动实现,用于驱动跟随者实际移动 */
|
||||
class FakeTopImpl implements IHeroMoveTopImpl {
|
||||
predicate(): IPassPredicate {
|
||||
return { canPass: () => true, shouldHit: () => false };
|
||||
}
|
||||
|
||||
inBound(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
async enter(): Promise<void> {}
|
||||
|
||||
async leave(): Promise<void> {}
|
||||
|
||||
async hit(): Promise<void> {}
|
||||
|
||||
async cannotEnter(): Promise<void> {}
|
||||
}
|
||||
|
||||
describe('HeroFollowersController members', () => {
|
||||
// 验证 addFollower 在勇士位置追加并触发 onAddFollower
|
||||
it('appends followers at the hero locator and notifies the add hook', () => {
|
||||
const env = createController();
|
||||
const added: [number, number][] = [];
|
||||
env.controller
|
||||
.addHook({
|
||||
onAddFollower: (follower, index) => {
|
||||
added.push([follower.num, index]);
|
||||
}
|
||||
})
|
||||
.load();
|
||||
env.location.setPos(2, 3);
|
||||
env.location.mover.setFaceDir(FaceDirection.Up);
|
||||
|
||||
const first = env.controller.addFollower(100);
|
||||
const second = env.controller.addFollower('ghost');
|
||||
|
||||
expect(first.num).toBe(100);
|
||||
expect(second.num).toBe(100);
|
||||
expect(first.location.x).toBe(2);
|
||||
expect(first.location.y).toBe(3);
|
||||
expect(first.location.getCurrentFaceDirection()).toBe(FaceDirection.Up);
|
||||
expect(first.rendering.alpha).toBe(1);
|
||||
expect(added).toEqual([
|
||||
[100, 0],
|
||||
[100, 1]
|
||||
]);
|
||||
expect(env.controller.getAllFollowers()).toEqual([first, second]);
|
||||
});
|
||||
|
||||
// 验证按索引与按数字/字符串 id 查询跟随者
|
||||
it('queries followers by index and by id', () => {
|
||||
const env = createController();
|
||||
const first = env.controller.addFollower(100);
|
||||
const second = env.controller.addFollower(100);
|
||||
|
||||
expect(env.controller.getFollower(0)).toBe(first);
|
||||
expect(env.controller.getFollower(1)).toBe(second);
|
||||
expect(env.controller.getFollower(5)).toBeNull();
|
||||
expect(
|
||||
[...env.controller.getFollowersById(100)].map(i => i[0])
|
||||
).toEqual([0, 1]);
|
||||
expect(
|
||||
[...env.controller.getFollowersById('ghost')].map(i => i[0])
|
||||
).toEqual([0, 1]);
|
||||
expect([...env.controller.getFollowersById(999)]).toEqual([]);
|
||||
});
|
||||
|
||||
// 验证 next/last 返回相邻跟随者且边界为 null
|
||||
it('links neighbours through next and last', () => {
|
||||
const env = createController();
|
||||
const first = env.controller.addFollower(100);
|
||||
const second = env.controller.addFollower(100);
|
||||
const third = env.controller.addFollower(100);
|
||||
|
||||
expect(first.next()).toBe(second);
|
||||
expect(second.next()).toBe(third);
|
||||
expect(third.next()).toBeNull();
|
||||
expect(third.last()).toBe(second);
|
||||
expect(first.last()).toBeNull();
|
||||
});
|
||||
|
||||
// 验证移除单个或全部跟随者都会触发 onRemoveFollower
|
||||
it('removes one or all followers and notifies the remove hook', async () => {
|
||||
const env = createController();
|
||||
const removed: number[] = [];
|
||||
env.controller
|
||||
.addHook({
|
||||
onRemoveFollower: (_follower, index) => {
|
||||
removed.push(index);
|
||||
}
|
||||
})
|
||||
.load();
|
||||
env.controller.addFollower(100);
|
||||
const second = env.controller.addFollower(100);
|
||||
|
||||
await env.controller.removeFollower(0);
|
||||
expect(env.controller.getAllFollowers()).toEqual([second]);
|
||||
expect(removed).toEqual([0]);
|
||||
|
||||
await env.controller.removeFollower(9);
|
||||
expect(env.controller.getAllFollowers()).toEqual([second]);
|
||||
|
||||
await env.controller.removeAllFollowers();
|
||||
expect(env.controller.getAllFollowers()).toEqual([]);
|
||||
expect(removed).toEqual([0, 0]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('HeroFollowersController gathering', () => {
|
||||
// 验证同步聚集把跟随者吸附到勇士位置并同步朝向
|
||||
it('gathers followers synchronously onto the hero', () => {
|
||||
const env = createController();
|
||||
const gathered: boolean[] = [];
|
||||
env.controller
|
||||
.addHook({
|
||||
onGatherFollowers: sync => {
|
||||
gathered.push(sync);
|
||||
}
|
||||
})
|
||||
.load();
|
||||
const follower = env.controller.addFollower(100);
|
||||
env.location.setPos(5, 6);
|
||||
env.location.mover.setFaceDir(FaceDirection.Right);
|
||||
|
||||
env.controller.gatherFollowersSync();
|
||||
|
||||
expect(follower.location.x).toBe(5);
|
||||
expect(follower.location.y).toBe(6);
|
||||
expect(follower.location.getCurrentFaceDirection()).toBe(
|
||||
FaceDirection.Right
|
||||
);
|
||||
expect(gathered).toEqual([true]);
|
||||
});
|
||||
|
||||
// 验证异步聚集等待移动结束并按勇士移动方向跟进一步
|
||||
it('gathers followers asynchronously and waits for the movement', async () => {
|
||||
const env = createController();
|
||||
const follower = env.controller.addFollower(100);
|
||||
const gathered: boolean[] = [];
|
||||
env.controller
|
||||
.addHook({
|
||||
onGatherFollowers: sync => {
|
||||
gathered.push(sync);
|
||||
}
|
||||
})
|
||||
.load();
|
||||
env.location.mover.useTopImplementation(new FakeTopImpl());
|
||||
follower.location.mover.useTopImplementation(new FakeTopImpl());
|
||||
|
||||
env.location.mover.step(FaceDirection.Right);
|
||||
const controller = env.location.mover.start();
|
||||
expect(controller).not.toBeNull();
|
||||
await controller!.onEnd;
|
||||
expect(env.location.x).toBe(1);
|
||||
|
||||
await env.controller.gatherFollowers();
|
||||
|
||||
expect(follower.location.x).toBe(1);
|
||||
expect(follower.location.y).toBe(0);
|
||||
expect(gathered).toEqual([false]);
|
||||
});
|
||||
|
||||
// 验证未知字符串跟随者 id 告警 142 并退回数字 0
|
||||
it('warns code 142 for an unknown string follower id', () => {
|
||||
const env = createController();
|
||||
|
||||
const result = logger.catch(() =>
|
||||
env.controller.addFollower('missing')
|
||||
);
|
||||
|
||||
expect(result.info.map(info => info.code)).toContain(142);
|
||||
expect(result.ret.num).toBe(0);
|
||||
});
|
||||
});
|
||||
235
packages-user/data-base/src/hero/items.test.ts
Normal file
235
packages-user/data-base/src/hero/items.test.ts
Normal file
@ -0,0 +1,235 @@
|
||||
// 测试 HeroItems 构件:常量/消耗计数、装备路由、拾取效果、未知输入与 useItem 分类行为
|
||||
import { afterAll, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
type IDataCommon,
|
||||
type IHeroAttr,
|
||||
type IItemRawData,
|
||||
ItemCategory,
|
||||
ItemStore,
|
||||
TileStore,
|
||||
TileType
|
||||
} from '@user/data-common';
|
||||
import { HeroItems } from './items';
|
||||
|
||||
vi.hoisted(() => {
|
||||
vi.stubGlobal('main', { replayChecking: true });
|
||||
vi.stubGlobal('location', { origin: 'http://localhost' });
|
||||
Map.prototype.getOrInsert ??= function <K, V>(
|
||||
this: Map<K, V>,
|
||||
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 <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;
|
||||
};
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
interface TestEnv {
|
||||
state: IDataCommon;
|
||||
tileStore: IDataCommon['tileStore'];
|
||||
itemStore: IDataCommon['itemStore'];
|
||||
items: HeroItems<IHeroAttr>;
|
||||
}
|
||||
|
||||
interface ItemFixture {
|
||||
item: IItemRawData<IHeroAttr>;
|
||||
useEffect: ReturnType<typeof vi.fn>;
|
||||
canUse: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
/** 构造一个装配勇士道具对象的测试环境 */
|
||||
function createEnv(): TestEnv {
|
||||
const tileStore = new TileStore();
|
||||
const itemStore = new ItemStore<IHeroAttr, unknown>();
|
||||
const state = { tileStore, itemStore } as never;
|
||||
return {
|
||||
state,
|
||||
tileStore,
|
||||
itemStore,
|
||||
items: new HeroItems<IHeroAttr>(state)
|
||||
};
|
||||
}
|
||||
|
||||
/** 构造一个带可观测效果的内联道具定义 */
|
||||
function createItem(
|
||||
num: number,
|
||||
id: string,
|
||||
category: ItemCategory,
|
||||
allowed: boolean = true
|
||||
): ItemFixture {
|
||||
const useEffect = vi.fn();
|
||||
const canUse = vi.fn(() => allowed);
|
||||
const item: IItemRawData<IHeroAttr> = {
|
||||
num,
|
||||
id,
|
||||
category,
|
||||
name: id,
|
||||
text: id,
|
||||
hideInToolbox: false,
|
||||
effect: { useEvent: null, useEffect, canUse },
|
||||
equip: {
|
||||
slots: [0],
|
||||
animate: 'sword',
|
||||
value: new Map(),
|
||||
percentage: new Map(),
|
||||
loadEvent: null,
|
||||
unloadEvent: null
|
||||
}
|
||||
};
|
||||
return { item, useEffect, canUse };
|
||||
}
|
||||
|
||||
/** 向图块与道具存储注册一个道具定义 */
|
||||
function registerItem(env: TestEnv, item: IItemRawData<IHeroAttr>): void {
|
||||
env.tileStore.addTile({
|
||||
num: item.num,
|
||||
id: item.id,
|
||||
events: {},
|
||||
type: TileType.Item,
|
||||
pass: { onlyEvents: false, inPass: 15, outPass: 15 },
|
||||
eventPass: true
|
||||
});
|
||||
env.itemStore.addItem(item);
|
||||
}
|
||||
|
||||
describe('HeroItems counting', () => {
|
||||
// 验证常量道具叠加计数并在数量归零时删除
|
||||
it('increments constant items and deletes them at zero', () => {
|
||||
const env = createEnv();
|
||||
registerItem(env, createItem(20, 'key', ItemCategory.Constant).item);
|
||||
|
||||
env.items.addItem(20, 2);
|
||||
expect(env.items.itemCount(20)).toBe(2);
|
||||
expect(env.items.getItemState(20)?.id).toBe('key');
|
||||
|
||||
env.items.addItem(20, 3);
|
||||
expect(env.items.itemCount(20)).toBe(5);
|
||||
|
||||
env.items.addItem(20, -5);
|
||||
expect(env.items.itemCount(20)).toBe(0);
|
||||
expect(env.items.getItemState(20)).toBeNull();
|
||||
});
|
||||
|
||||
// 验证 getItem 等价于增加一个道具并支持字符串 id
|
||||
it('gets one item through getItem and string ids', () => {
|
||||
const env = createEnv();
|
||||
registerItem(env, createItem(20, 'key', ItemCategory.Constant).item);
|
||||
|
||||
env.items.getItem('key');
|
||||
|
||||
expect(env.items.itemCount(20)).toBe(1);
|
||||
expect(env.items.itemCount('key')).toBe(1);
|
||||
});
|
||||
|
||||
// 验证装备类道具被路由到装备实例存储
|
||||
it('routes equipment items into the equipment store', () => {
|
||||
const env = createEnv();
|
||||
registerItem(env, createItem(30, 'sword', ItemCategory.Equipment).item);
|
||||
|
||||
env.items.addItem(30, 2);
|
||||
|
||||
expect(env.items.equipment.count(30)).toBe(2);
|
||||
expect(env.items.itemCount(30)).toBe(0);
|
||||
});
|
||||
|
||||
// 验证拾取类道具按数量逐个触发效果且不占用背包
|
||||
it('triggers pick effects once per added item', () => {
|
||||
const env = createEnv();
|
||||
const fixture = createItem(40, 'coin', ItemCategory.Pick);
|
||||
registerItem(env, fixture.item);
|
||||
|
||||
env.items.addItem(40, 3);
|
||||
|
||||
expect(fixture.useEffect).toHaveBeenCalledTimes(3);
|
||||
expect(env.items.itemCount(40)).toBe(0);
|
||||
});
|
||||
|
||||
// 验证未知图块、缺失定义与无效 id 都安全忽略
|
||||
it('ignores unknown tiles and missing item definitions', () => {
|
||||
const env = createEnv();
|
||||
env.tileStore.addTile({
|
||||
num: 77,
|
||||
id: 'ghost',
|
||||
events: {},
|
||||
type: TileType.Item,
|
||||
pass: { onlyEvents: false, inPass: 15, outPass: 15 },
|
||||
eventPass: true
|
||||
});
|
||||
|
||||
env.items.addItem(99);
|
||||
env.items.getItem('missing');
|
||||
env.items.addItem(77, 3);
|
||||
|
||||
expect(env.items.itemCount(99)).toBe(0);
|
||||
expect(env.items.itemCount(77)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('HeroItems useItem', () => {
|
||||
// 验证 useItem 仅对常量与可用的消耗类生效,并对装备/拾取/缺失返回 false
|
||||
it('uses constant and consumable items only when allowed', () => {
|
||||
const env = createEnv();
|
||||
const constant = createItem(20, 'key', ItemCategory.Constant);
|
||||
const consumable = createItem(21, 'potion', ItemCategory.Consumable);
|
||||
const blocked = createItem(
|
||||
22,
|
||||
'sealed',
|
||||
ItemCategory.Consumable,
|
||||
false
|
||||
);
|
||||
const equipment = createItem(30, 'sword', ItemCategory.Equipment);
|
||||
const pick = createItem(40, 'coin', ItemCategory.Pick);
|
||||
for (const fixture of [
|
||||
constant,
|
||||
consumable,
|
||||
blocked,
|
||||
equipment,
|
||||
pick
|
||||
]) {
|
||||
registerItem(env, fixture.item);
|
||||
}
|
||||
|
||||
expect(env.items.useItem(99)).toBe(false);
|
||||
|
||||
env.items.addItem(20);
|
||||
expect(env.items.useItem(20)).toBe(true);
|
||||
expect(constant.useEffect).toHaveBeenCalledTimes(1);
|
||||
expect(env.items.itemCount(20)).toBe(1);
|
||||
|
||||
env.items.addItem(21, 2);
|
||||
expect(env.items.useItem(21)).toBe(true);
|
||||
expect(env.items.itemCount(21)).toBe(1);
|
||||
expect(env.items.useItem(21)).toBe(true);
|
||||
expect(env.items.itemCount(21)).toBe(0);
|
||||
expect(env.items.getItemState(21)).toBeNull();
|
||||
|
||||
env.items.addItem(22);
|
||||
expect(env.items.useItem(22)).toBe(false);
|
||||
expect(blocked.useEffect).not.toHaveBeenCalled();
|
||||
|
||||
env.items.addItem(30);
|
||||
expect(env.items.useItem(30)).toBe(false);
|
||||
expect(env.items.equipment.count(30)).toBe(1);
|
||||
|
||||
env.items.addItem(40);
|
||||
expect(env.items.useItem(40)).toBe(false);
|
||||
expect(pick.useEffect).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
123
packages-user/data-base/src/hero/location.test.ts
Normal file
123
packages-user/data-base/src/hero/location.test.ts
Normal file
@ -0,0 +1,123 @@
|
||||
// 测试 HeroLocation 构件:初始坐标/楼层、朝向一致性以及位置与楼层钩子
|
||||
import { afterAll, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
type IDataCommon,
|
||||
Dir8FaceHandler,
|
||||
FaceDirection,
|
||||
ItemStore,
|
||||
TileStore
|
||||
} from '@user/data-common';
|
||||
import { HeroLocation } from './location';
|
||||
|
||||
vi.hoisted(() => {
|
||||
vi.stubGlobal('main', { replayChecking: true });
|
||||
vi.stubGlobal('location', { origin: 'http://localhost' });
|
||||
Map.prototype.getOrInsert ??= function <K, V>(
|
||||
this: Map<K, V>,
|
||||
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 <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;
|
||||
};
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
/** 构造一个仅包含图块与道具存储的公共层假对象 */
|
||||
function createState(): IDataCommon {
|
||||
return {
|
||||
tileStore: new TileStore(),
|
||||
itemStore: new ItemStore()
|
||||
} as never;
|
||||
}
|
||||
|
||||
/** 构造一个八方向朝向处理器 */
|
||||
function createFaceHandler(): Dir8FaceHandler {
|
||||
return new Dir8FaceHandler();
|
||||
}
|
||||
|
||||
/** 构造一个停在指定定位器上的勇士位置对象 */
|
||||
function createLocation(): HeroLocation {
|
||||
return new HeroLocation(
|
||||
createState(),
|
||||
{ x: 3, y: 4, direction: FaceDirection.Right },
|
||||
createFaceHandler()
|
||||
);
|
||||
}
|
||||
|
||||
describe('HeroLocation position and floor', () => {
|
||||
// 验证初始坐标、未定楼层与朝向来自构造时的定位器
|
||||
it('initializes position, face direction and an undefined floor', () => {
|
||||
const location = createLocation();
|
||||
|
||||
expect(location.x).toBe(3);
|
||||
expect(location.y).toBe(4);
|
||||
expect(location.floorId).toBeUndefined();
|
||||
expect(location.getCurrentFaceDirection()).toBe(FaceDirection.Right);
|
||||
expect(location.mover.faceDirection).toBe(FaceDirection.Right);
|
||||
});
|
||||
|
||||
// 验证 setPos 更新坐标并触发 onSetPos 钩子
|
||||
it('updates the position and notifies the onSetPos hook', () => {
|
||||
const location = createLocation();
|
||||
const calls: [number, number][] = [];
|
||||
location
|
||||
.addHook({
|
||||
onSetPos: (x, y) => {
|
||||
calls.push([x, y]);
|
||||
}
|
||||
})
|
||||
.load();
|
||||
|
||||
location.setPos(7, 9);
|
||||
|
||||
expect(location.x).toBe(7);
|
||||
expect(location.y).toBe(9);
|
||||
expect(calls).toEqual([[7, 9]]);
|
||||
});
|
||||
|
||||
// 验证 setFloor 更新楼层并触发 onSetFloor 钩子
|
||||
it('updates the floor and notifies the onSetFloor hook', () => {
|
||||
const location = createLocation();
|
||||
const floors: (string | undefined)[] = [];
|
||||
location
|
||||
.addHook({
|
||||
onSetFloor: floorId => {
|
||||
floors.push(floorId);
|
||||
}
|
||||
})
|
||||
.load();
|
||||
|
||||
location.setFloor('F2');
|
||||
expect(location.floorId).toBe('F2');
|
||||
|
||||
location.setFloor(undefined);
|
||||
expect(location.floorId).toBeUndefined();
|
||||
expect(floors).toEqual(['F2', undefined]);
|
||||
});
|
||||
|
||||
// 验证朝向经由共享移动器读写
|
||||
it('reports the face direction through the shared mover', () => {
|
||||
const location = createLocation();
|
||||
|
||||
location.mover.setFaceDir(FaceDirection.Down);
|
||||
|
||||
expect(location.getCurrentFaceDirection()).toBe(FaceDirection.Down);
|
||||
});
|
||||
});
|
||||
187
packages-user/data-base/src/hero/state.test.ts
Normal file
187
packages-user/data-base/src/hero/state.test.ts
Normal file
@ -0,0 +1,187 @@
|
||||
// 测试 HeroState 装配:子系统装配、属性视图、修饰器注册与 changeFloor 钩子顺序
|
||||
import { afterAll, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
type IDataCommon,
|
||||
type IHeroAttr,
|
||||
Dir8FaceHandler,
|
||||
FaceDirection,
|
||||
ItemStore,
|
||||
TileStore
|
||||
} from '@user/data-common';
|
||||
import { logger } from '@motajs/common';
|
||||
import { HeroAttribute } from './attribute';
|
||||
import { ValueModifier } from './modifier';
|
||||
import { HeroState } from './state';
|
||||
import { type IHeroAttribute, type IHeroState } from './types';
|
||||
|
||||
vi.hoisted(() => {
|
||||
vi.stubGlobal('main', { replayChecking: true });
|
||||
vi.stubGlobal('location', { origin: 'http://localhost' });
|
||||
Map.prototype.getOrInsert ??= function <K, V>(
|
||||
this: Map<K, V>,
|
||||
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 <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;
|
||||
};
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
/** 构造一个仅包含图块与道具存储的公共层假对象 */
|
||||
function createState(): IDataCommon {
|
||||
return {
|
||||
tileStore: new TileStore(),
|
||||
itemStore: new ItemStore()
|
||||
} as never;
|
||||
}
|
||||
|
||||
/** 构造一个八方向朝向处理器 */
|
||||
function createFaceHandler(): Dir8FaceHandler {
|
||||
return new Dir8FaceHandler();
|
||||
}
|
||||
|
||||
/** 构造一份合成的勇士基础属性 */
|
||||
function createBaseAttr(): IHeroAttr {
|
||||
return {
|
||||
name: 'hero',
|
||||
hp: 100,
|
||||
hpmax: 100,
|
||||
atk: 10,
|
||||
def: 5,
|
||||
mdef: 0,
|
||||
mana: 0,
|
||||
manamax: 0,
|
||||
money: 0,
|
||||
exp: 0
|
||||
};
|
||||
}
|
||||
|
||||
/** 构造一个装配完成的勇士状态对象,可注入既有属性对象 */
|
||||
function createHeroState(
|
||||
attribute?: IHeroAttribute<IHeroAttr>
|
||||
): IHeroState<IHeroAttr> {
|
||||
return new HeroState<IHeroAttr>(
|
||||
createState(),
|
||||
createFaceHandler(),
|
||||
attribute ?? new HeroAttribute<IHeroAttr>(createBaseAttr())
|
||||
);
|
||||
}
|
||||
|
||||
describe('HeroState assembly', () => {
|
||||
// 验证构造器装配全部子系统并停在默认定位器
|
||||
it('assembles every subsystem at the default locator', () => {
|
||||
const hero = createHeroState();
|
||||
|
||||
expect(hero.location).toBeDefined();
|
||||
expect(hero.rendering.alpha).toBe(1);
|
||||
expect(hero.followers.getAllFollowers()).toEqual([]);
|
||||
expect(hero.items.equipment).toBeDefined();
|
||||
expect(hero.equip.slots).toEqual([]);
|
||||
expect(hero.getLocation()).toEqual({
|
||||
x: 0,
|
||||
y: 0,
|
||||
direction: FaceDirection.Down
|
||||
});
|
||||
});
|
||||
|
||||
// 验证可修改属性返回共享对象、只读视图共享引用、独立属性为克隆
|
||||
it('exposes shared, readonly and isolated attribute views', () => {
|
||||
const attribute = new HeroAttribute<IHeroAttr>(createBaseAttr());
|
||||
const hero = createHeroState(attribute);
|
||||
|
||||
expect(hero.getModifiableAttribute()).toBe(attribute);
|
||||
expect(hero.getAttribute()).toBe(attribute);
|
||||
|
||||
const isolated = hero.getIsolatedAttribute();
|
||||
expect(isolated).not.toBe(attribute);
|
||||
isolated.set('hp', 1);
|
||||
expect(attribute.getBaseAttribute('hp')).toBe(100);
|
||||
});
|
||||
|
||||
// 验证 attachAttribute 替换绑定的属性对象
|
||||
it('replaces the bound attribute through attachAttribute', () => {
|
||||
const hero = createHeroState();
|
||||
const replacement = new HeroAttribute<IHeroAttr>(createBaseAttr());
|
||||
|
||||
hero.attachAttribute(replacement);
|
||||
|
||||
expect(hero.getModifiableAttribute()).toBe(replacement);
|
||||
expect(hero.getAttribute()).toBe(replacement);
|
||||
});
|
||||
});
|
||||
|
||||
describe('HeroState modifier registry', () => {
|
||||
// 验证已注册类型可创建并插入修饰器,未知类型告警 116 并返回 null
|
||||
it('creates registered modifiers and warns code 116 for unknown types', () => {
|
||||
const hero = createHeroState();
|
||||
hero.registerModifier('@system/value', () => new ValueModifier(5));
|
||||
|
||||
expect(hero.createModifier('@system/value')).not.toBeNull();
|
||||
expect(
|
||||
hero.createAndInsertModifier('@system/value', 'hp')
|
||||
).not.toBeNull();
|
||||
expect(hero.getModifiableAttribute().getFinalAttribute('hp')).toBe(105);
|
||||
|
||||
const unknown = logger.catch(() =>
|
||||
hero.createAndInsertModifier('@missing/type', 'hp')
|
||||
);
|
||||
expect(unknown.ret).toBeNull();
|
||||
expect(unknown.info.map(info => info.code)).toContain(116);
|
||||
});
|
||||
});
|
||||
|
||||
describe('HeroState changeFloor', () => {
|
||||
// 验证 changeFloor 依次触发 before、位置/楼层更新与 after 钩子
|
||||
it('changes floor with the documented hook order', async () => {
|
||||
const hero = createHeroState();
|
||||
const calls: string[] = [];
|
||||
hero.addHook({
|
||||
onBeforeChangeFloor: async () => {
|
||||
calls.push('before');
|
||||
},
|
||||
onAfterChangeFloor: async () => {
|
||||
calls.push('after');
|
||||
}
|
||||
}).load();
|
||||
hero.location
|
||||
.addHook({
|
||||
onSetFloor: () => {
|
||||
calls.push('floor');
|
||||
}
|
||||
})
|
||||
.load();
|
||||
|
||||
await hero.changeFloor({
|
||||
target: 'F2',
|
||||
x: 5,
|
||||
y: 6,
|
||||
face: FaceDirection.Up
|
||||
});
|
||||
|
||||
expect(calls).toEqual(['before', 'floor', 'after']);
|
||||
expect(hero.getLocation()).toEqual({
|
||||
x: 5,
|
||||
y: 6,
|
||||
direction: FaceDirection.Up
|
||||
});
|
||||
expect(hero.location.floorId).toBe('F2');
|
||||
expect(hero.location.mover.moveDirection).toBe(FaceDirection.Up);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user