diff --git a/packages-user/data-base/src/hero/equipStore.test.ts b/packages-user/data-base/src/hero/equipStore.test.ts new file mode 100644 index 0000000..85f0020 --- /dev/null +++ b/packages-user/data-base/src/hero/equipStore.test.ts @@ -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 ( + this: Map, + 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 ( + this: Map, + 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; + +interface TestEnv { + state: IDataCommon; + tileStore: IDataCommon['tileStore']; + itemStore: IDataCommon['itemStore']; + store: HeroEquipsStore; +} + +/** 构造一个仅含图块与道具存储的公共层假对象 */ +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(state) + }; +} + +/** 构造一个带合成装备属性的道具定义 */ +function createItem( + num: number, + id: string, + value: [HeroKey, number][] = [], + percentage: [HeroKey, number][] = [] +): IItemRawData { + 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): 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 { + compare(handler: IEquipmentSortHandler): 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); + }); +}); diff --git a/packages-user/data-base/src/hero/equipment.test.ts b/packages-user/data-base/src/hero/equipment.test.ts new file mode 100644 index 0000000..763e11b --- /dev/null +++ b/packages-user/data-base/src/hero/equipment.test.ts @@ -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 ( + this: Map, + 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 ( + this: Map, + 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; + +interface TestEnv { + state: IDataCommon; + tileStore: IDataCommon['tileStore']; + itemStore: IDataCommon['itemStore']; + store: HeroEquipsStore; + equipment: HeroEquipment; + attribute: HeroAttribute; +} + +/** 构造一份合成的勇士基础属性 */ +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 { + 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(); + const state = { tileStore, itemStore } as never; + const attribute = new HeroAttribute(createBaseAttr()); + const store = new HeroEquipsStore(state); + const equipment = new HeroEquipment(store, attribute); + return { state, tileStore, itemStore, store, equipment, attribute }; +} + +/** 向图块与道具存储注册一个装备道具定义 */ +function registerItem(env: TestEnv, item: IItemRawData): 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); + }); +}); diff --git a/packages-user/data-base/src/hero/follower.test.ts b/packages-user/data-base/src/hero/follower.test.ts new file mode 100644 index 0000000..075d0d9 --- /dev/null +++ b/packages-user/data-base/src/hero/follower.test.ts @@ -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 ( + this: Map, + 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 ( + this: Map, + 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 {} + + async leave(): Promise {} + + async hit(): Promise {} + + async cannotEnter(): Promise {} +} + +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); + }); +}); diff --git a/packages-user/data-base/src/hero/items.test.ts b/packages-user/data-base/src/hero/items.test.ts new file mode 100644 index 0000000..8b2a8b6 --- /dev/null +++ b/packages-user/data-base/src/hero/items.test.ts @@ -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 ( + this: Map, + 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 ( + this: Map, + 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; +} + +interface ItemFixture { + item: IItemRawData; + useEffect: ReturnType; + canUse: ReturnType; +} + +/** 构造一个装配勇士道具对象的测试环境 */ +function createEnv(): TestEnv { + const tileStore = new TileStore(); + const itemStore = new ItemStore(); + const state = { tileStore, itemStore } as never; + return { + state, + tileStore, + itemStore, + items: new HeroItems(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 = { + 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): 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); + }); +}); diff --git a/packages-user/data-base/src/hero/location.test.ts b/packages-user/data-base/src/hero/location.test.ts new file mode 100644 index 0000000..bfb1576 --- /dev/null +++ b/packages-user/data-base/src/hero/location.test.ts @@ -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 ( + this: Map, + 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 ( + this: Map, + 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); + }); +}); diff --git a/packages-user/data-base/src/hero/state.test.ts b/packages-user/data-base/src/hero/state.test.ts new file mode 100644 index 0000000..daf2555 --- /dev/null +++ b/packages-user/data-base/src/hero/state.test.ts @@ -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 ( + this: Map, + 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 ( + this: Map, + 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 +): IHeroState { + return new HeroState( + createState(), + createFaceHandler(), + attribute ?? new HeroAttribute(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(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(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); + }); +});