672 lines
26 KiB
TypeScript
672 lines
26 KiB
TypeScript
/**
|
||
* Comprehensive Unit Test Suite for Milestone M11.4 (Issue #407):
|
||
* Drop Item to UiInventoryItem Bridge & Pickup Loop.
|
||
*
|
||
* Verifies strict Diablo II v1.13c ground truth parity:
|
||
* 1. Quality Mapping across all tiers (White, Blue, Yellow, Green, Gold, Runes, Potions, Gems)
|
||
* 2. 100% invFile Atlas Matching against BAKED_UI_MANIFEST.itemRects
|
||
* 3. BodyLoc1/BodyLoc2 Equipment Slots Resolution (allowedSlots) with category inheritance
|
||
* 4. Bilingual naming (nameZh, baseNameZh) and rich tooltip stats formatting
|
||
* 5. Normal and Telekinesis ground item pickup conversion via itemToUiInventoryItem
|
||
* 6. Gold accumulation clamped to PLAYER_GOLD_CAP (2,500,000)
|
||
* 7. Inventory full refusal bounce, audio feedback, and notification retention
|
||
*/
|
||
|
||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||
import type { Item, ItemBase } from '../src/game/items.ts'
|
||
import { ItemQuality } from '../src/game/items.ts'
|
||
import { getEmbeddedDropTables } from '../src/game/embedded-drop-tables.ts'
|
||
import {
|
||
itemToUiInventoryItem,
|
||
resolveAllowedSlots,
|
||
} from '../src/ui/item-bridge.ts'
|
||
import {
|
||
InventoryPanel,
|
||
resolveItemSpriteRect,
|
||
PLAYER_GOLD_CAP,
|
||
} from '../src/ui/inventory.ts'
|
||
import { BAKED_UI_MANIFEST } from '../src/ui/baked-ui-meta.ts'
|
||
import { GameEngine } from '../src/game/engine.ts'
|
||
import { DEMO_EXPERIENCE, DEMO_SKILLS, DEMO_QUESTS } from '../src/game/demo-data.ts'
|
||
import { SceneMouseController, castSkill } from '../src/scene/act-scene.ts'
|
||
|
||
describe('Milestone M11.4 (Issue #407) — Drop Item to UiInventoryItem Bridge & Pickup Loop', () => {
|
||
const dropTables = getEmbeddedDropTables()
|
||
|
||
type TestBase = Partial<ItemBase> & { twoHanded?: boolean; oneOrTwoHanded?: boolean }
|
||
|
||
// Helper to create synthetic engine items
|
||
function createTestItem(overrides: Omit<Partial<Item>, 'base'> & { base?: TestBase }): Item {
|
||
const b = overrides.base ?? {}
|
||
const base: ItemBase = {
|
||
id: b.id ?? 'hax',
|
||
name: b.name ?? 'Hand Axe',
|
||
kind: b.kind ?? 'weapon',
|
||
invWidth: b.invWidth ?? 1,
|
||
invHeight: b.invHeight ?? 2,
|
||
maxStack: b.maxStack ?? 1,
|
||
value: b.value ?? 100,
|
||
damage: b.damage ?? 6,
|
||
defense: b.defense ?? 0,
|
||
tags: b.tags ?? ['weap', 'axe'],
|
||
level: b.level ?? 1,
|
||
...(b as any),
|
||
}
|
||
|
||
return {
|
||
prefix: null,
|
||
suffix: null,
|
||
level: overrides.level ?? 1,
|
||
name: overrides.name ?? base.name,
|
||
stats: overrides.stats ?? {},
|
||
invWidth: overrides.invWidth ?? base.invWidth,
|
||
invHeight: overrides.invHeight ?? base.invHeight,
|
||
stack: overrides.stack ?? 1,
|
||
value: overrides.value ?? base.value,
|
||
...overrides,
|
||
base: base as ItemBase,
|
||
}
|
||
}
|
||
|
||
describe('1. Quality Tier Mapping & Bilingual Localization', () => {
|
||
it('maps Normal / White quality with bilingual item and base names', () => {
|
||
const item = createTestItem({
|
||
base: { id: 'cap', name: 'Cap', kind: 'armor', defense: 3, tags: ['helm', 'armo'], invWidth: 2, invHeight: 2 },
|
||
quality: ItemQuality.NORMAL,
|
||
})
|
||
|
||
const uiItem = itemToUiInventoryItem(item, dropTables)
|
||
expect(uiItem.quality).toBe('normal')
|
||
expect(uiItem.name).toBe('Cap')
|
||
expect(uiItem.nameZh).toBe('帽子 (Cap)')
|
||
expect(uiItem.baseNameZh).toBe('帽子 (Cap)')
|
||
})
|
||
|
||
it('maps Magic / Blue quality with rolled affixes and bilingual naming', () => {
|
||
const item = createTestItem({
|
||
base: { id: 'lea', name: 'Leather Armor', kind: 'armor', defense: 14, tags: ['tors', 'armo'], invWidth: 2, invHeight: 3 },
|
||
quality: ItemQuality.MAGIC,
|
||
name: 'Sturdy Leather Armor',
|
||
rolledMagicAffixes: {
|
||
name: 'Sturdy Leather Armor',
|
||
prefix: { id: 'Sturdy', name: 'Sturdy', mods: [{ code: 'ac%', min: 20, max: 30, value: 25 }] },
|
||
},
|
||
})
|
||
|
||
const uiItem = itemToUiInventoryItem(item, dropTables)
|
||
expect(uiItem.quality).toBe('magic')
|
||
expect(uiItem.name).toBe('Sturdy Leather Armor')
|
||
expect(uiItem.nameZh).toContain('皮甲')
|
||
expect(uiItem.baseNameZh).toContain('皮甲 (Leather Armor)')
|
||
})
|
||
|
||
it('maps Rare / Yellow quality with rolled rare affixes and bilingual naming', () => {
|
||
const item = createTestItem({
|
||
base: { id: 'rin', name: 'Ring', kind: 'misc', tags: ['ring'], invWidth: 1, invHeight: 1 },
|
||
quality: ItemQuality.RARE,
|
||
name: 'Doom Gyre',
|
||
rolledRareAffixes: {
|
||
name: 'Doom Gyre',
|
||
prefix1: { id: 'Doom', name: 'Doom' },
|
||
suffix1: { id: 'Gyre', name: 'Gyre' },
|
||
},
|
||
})
|
||
|
||
const uiItem = itemToUiInventoryItem(item, dropTables)
|
||
expect(uiItem.quality).toBe('rare')
|
||
expect(uiItem.name).toBe('Doom Gyre')
|
||
expect(uiItem.nameZh).toContain('毁灭之旋回')
|
||
expect(uiItem.baseNameZh).toContain('戒指 (Ring)')
|
||
})
|
||
|
||
it('maps Set / Green quality with set definition, pieces, and bonuses', () => {
|
||
const item = createTestItem({
|
||
base: { id: 'rin', name: 'Ring', kind: 'misc', tags: ['ring'], invWidth: 1, invHeight: 1 },
|
||
quality: ItemQuality.SET,
|
||
name: "Cathan's Seal",
|
||
setItemDef: {
|
||
index: "Cathan's Seal",
|
||
set: "Cathan's Traps",
|
||
invfile: 'invrin',
|
||
},
|
||
})
|
||
|
||
const uiItem = itemToUiInventoryItem(item, dropTables)
|
||
expect(uiItem.quality).toBe('set')
|
||
expect(uiItem.name).toBe("Cathan's Seal")
|
||
expect(uiItem.nameZh).toContain("Cathan's Seal")
|
||
expect(uiItem.baseNameZh).toContain("Cathan's Traps")
|
||
})
|
||
|
||
it('maps Unique / Gold quality with unique item definition and color dye', () => {
|
||
const item = createTestItem({
|
||
base: { id: 'uap', name: 'Shako', kind: 'armor', defense: 141, tags: ['helm', 'armo'], invWidth: 2, invHeight: 2 },
|
||
quality: ItemQuality.UNIQUE,
|
||
name: 'Harlequin Crest',
|
||
uniqueItemDef: {
|
||
index: 'Harlequin Crest',
|
||
invtransform: 'cgrn',
|
||
},
|
||
})
|
||
|
||
const uiItem = itemToUiInventoryItem(item, dropTables)
|
||
expect(uiItem.quality).toBe('unique')
|
||
expect(uiItem.name).toBe('Harlequin Crest')
|
||
expect(uiItem.nameZh).toContain('谐角之冠')
|
||
expect(uiItem.baseNameZh).toContain('军帽 (Shako)')
|
||
expect(uiItem.invtransform).toBe('cgrn')
|
||
})
|
||
|
||
it('maps Runes correctly with quality rune', () => {
|
||
const item = createTestItem({
|
||
base: { id: 'r01', name: 'El Rune', kind: 'misc', tags: ['rune'], invWidth: 1, invHeight: 1 },
|
||
code: 'r01',
|
||
})
|
||
|
||
const uiItem = itemToUiInventoryItem(item, dropTables)
|
||
expect(uiItem.quality).toBe('rune')
|
||
expect(uiItem.nameZh).toContain('艾尔')
|
||
})
|
||
|
||
it('maps Potions and Gems correctly as normal quality misc items', () => {
|
||
const potion = createTestItem({
|
||
base: { id: 'hp1', name: 'Minor Healing Potion', kind: 'misc', tags: ['potion'], invWidth: 1, invHeight: 1 },
|
||
})
|
||
const uiPotion = itemToUiInventoryItem(potion, dropTables)
|
||
expect(uiPotion.quality).toBe('normal')
|
||
expect(uiPotion.nameZh).toContain('轻微治疗药剂')
|
||
|
||
const gem = createTestItem({
|
||
base: { id: 'gsv', name: 'Flawed Amethyst', kind: 'misc', tags: ['gem'], invWidth: 1, invHeight: 1 },
|
||
})
|
||
const uiGem = itemToUiInventoryItem(gem, dropTables)
|
||
expect(uiGem.quality).toBe('normal')
|
||
expect(uiGem.nameZh).toContain('裂开的紫宝石')
|
||
})
|
||
})
|
||
|
||
describe('2. 100% invFile Atlas Resolution against BAKED_UI_MANIFEST.itemRects', () => {
|
||
it('guarantees resolved invFile exists in BAKED_UI_MANIFEST.itemRects for every item', () => {
|
||
const testCases = [
|
||
{ id: 'hax', name: 'Hand Axe', kind: 'weapon' },
|
||
{ id: 'swd', name: 'Short Sword', kind: 'weapon' },
|
||
{ id: 'cap', name: 'Cap', kind: 'armor' },
|
||
{ id: 'lea', name: 'Leather Armor', kind: 'armor' },
|
||
{ id: 'buc', name: 'Buckler', kind: 'armor' },
|
||
{ id: 'rin', name: 'Ring', kind: 'misc' },
|
||
{ id: 'amu', name: 'Amulet', kind: 'misc' },
|
||
{ id: 'jew', name: 'Jewel', kind: 'misc' },
|
||
{ id: 'cm1', name: 'Small Charm', kind: 'misc' },
|
||
{ id: 'cm2', name: 'Large Charm', kind: 'misc' },
|
||
{ id: 'cm3', name: 'Grand Charm', kind: 'misc' },
|
||
{ id: 'r01', name: 'El Rune', kind: 'misc' },
|
||
{ id: 'r33', name: 'Zod Rune', kind: 'misc' },
|
||
{ id: 'hp1', name: 'Minor Healing Potion', kind: 'misc' },
|
||
{ id: 'tbk', name: 'Tome of Town Portal', kind: 'misc' },
|
||
{ id: 'ibk', name: 'Tome of Identify', kind: 'misc' },
|
||
{ id: 'box', name: 'Horadric Cube', kind: 'misc' },
|
||
]
|
||
|
||
for (const tc of testCases) {
|
||
const item = createTestItem({
|
||
base: { id: tc.id, name: tc.name, kind: tc.kind as any, invWidth: 1, invHeight: 1 },
|
||
code: tc.id,
|
||
})
|
||
const uiItem = itemToUiInventoryItem(item, dropTables)
|
||
|
||
expect(uiItem.invFile).toBeTruthy()
|
||
const rect = BAKED_UI_MANIFEST.itemRects[uiItem.invFile] ?? BAKED_UI_MANIFEST.itemRects[uiItem.invFile.toLowerCase()]
|
||
expect(rect, `Missing atlas rect for ${tc.name} (invFile: ${uiItem.invFile})`).toBeDefined()
|
||
expect(rect.w).toBeGreaterThan(0)
|
||
expect(rect.h).toBeGreaterThan(0)
|
||
|
||
const spriteRect = resolveItemSpriteRect(uiItem)
|
||
expect(spriteRect).not.toBeNull()
|
||
}
|
||
})
|
||
|
||
it('resolves unique Harlequin Crest to dyed invcap with cgrn transform', () => {
|
||
const shako = createTestItem({
|
||
base: { id: 'uap', name: 'Shako', kind: 'armor', invWidth: 2, invHeight: 2 },
|
||
name: 'Harlequin Crest',
|
||
quality: ItemQuality.UNIQUE,
|
||
uniqueItemDef: {
|
||
index: 'Harlequin Crest',
|
||
invtransform: 'cgrn',
|
||
},
|
||
})
|
||
const uiItem = itemToUiInventoryItem(shako, dropTables)
|
||
expect(uiItem.invFile).toBe('invcap')
|
||
expect(uiItem.invtransform).toBe('cgrn')
|
||
|
||
const rect = resolveItemSpriteRect(uiItem)
|
||
expect(rect).not.toBeNull()
|
||
})
|
||
|
||
it('resolves authentic charm sprites cm1 -> invch1, cm2 -> invch2, cm3 -> invch3', () => {
|
||
const sc = itemToUiInventoryItem(createTestItem({ base: { id: 'cm1', name: 'Small Charm', kind: 'misc', invWidth: 1, invHeight: 1 }, code: 'cm1' }))
|
||
const lc = itemToUiInventoryItem(createTestItem({ base: { id: 'cm2', name: 'Large Charm', kind: 'misc', invWidth: 1, invHeight: 2 }, code: 'cm2' }))
|
||
const gc = itemToUiInventoryItem(createTestItem({ base: { id: 'cm3', name: 'Grand Charm', kind: 'misc', invWidth: 1, invHeight: 3 }, code: 'cm3' }))
|
||
|
||
expect(sc.invFile).toBe('invch1')
|
||
expect(lc.invFile).toBe('invch2')
|
||
expect(gc.invFile).toBe('invch3')
|
||
expect(BAKED_UI_MANIFEST.itemRects[sc.invFile]).toBeDefined()
|
||
expect(BAKED_UI_MANIFEST.itemRects[lc.invFile]).toBeDefined()
|
||
expect(BAKED_UI_MANIFEST.itemRects[gc.invFile]).toBeDefined()
|
||
})
|
||
})
|
||
|
||
describe('3. BodyLoc1/BodyLoc2 Equipment Slots Resolution (allowedSlots)', () => {
|
||
it('resolves Helm equipment slot', () => {
|
||
const helm = createTestItem({ base: { id: 'cap', kind: 'armor', tags: ['helm'] } })
|
||
expect(resolveAllowedSlots(helm.base, dropTables)).toEqual(['helm'])
|
||
})
|
||
|
||
it('resolves Amulet equipment slot', () => {
|
||
const amulet = createTestItem({ base: { id: 'amu', kind: 'misc', tags: ['amul'] } })
|
||
expect(resolveAllowedSlots(amulet.base, dropTables)).toEqual(['amulet'])
|
||
})
|
||
|
||
it('resolves Torso / Body Armor equipment slot', () => {
|
||
const armor = createTestItem({ base: { id: 'lea', kind: 'armor', tags: ['tors'] } })
|
||
expect(resolveAllowedSlots(armor.base, dropTables)).toEqual(['armor'])
|
||
})
|
||
|
||
it('resolves Gloves equipment slot', () => {
|
||
const gloves = createTestItem({ base: { id: 'lgl', kind: 'armor', tags: ['glov'] } })
|
||
expect(resolveAllowedSlots(gloves.base, dropTables)).toEqual(['gloves'])
|
||
})
|
||
|
||
it('resolves Boots equipment slot', () => {
|
||
const boots = createTestItem({ base: { id: 'lbt', kind: 'armor', tags: ['boot'] } })
|
||
expect(resolveAllowedSlots(boots.base, dropTables)).toEqual(['boots'])
|
||
})
|
||
|
||
it('resolves Belt equipment slot', () => {
|
||
const belt = createTestItem({ base: { id: 'lbl', kind: 'armor', tags: ['belt'] } })
|
||
expect(resolveAllowedSlots(belt.base, dropTables)).toEqual(['belt'])
|
||
})
|
||
|
||
it('resolves Ring equipment slot to ring1 and ring2', () => {
|
||
const ring = createTestItem({ base: { id: 'rin', kind: 'misc', tags: ['ring'] } })
|
||
expect(resolveAllowedSlots(ring.base, dropTables)).toEqual(['ring1', 'ring2'])
|
||
})
|
||
|
||
it('resolves One-Handed weapons to weapon1 and weapon2 (dual-wield capable)', () => {
|
||
const axe = createTestItem({ base: { id: 'hax', kind: 'weapon', tags: ['weap', 'axe'], twoHanded: false } })
|
||
expect(resolveAllowedSlots(axe.base, dropTables)).toEqual(['weapon1', 'weapon2'])
|
||
})
|
||
|
||
it('resolves Two-Handed weapons strictly to weapon1', () => {
|
||
const bow = createTestItem({ base: { id: 'sbw', kind: 'weapon', tags: ['weap', 'bow'], twoHanded: true } })
|
||
expect(resolveAllowedSlots(bow.base, dropTables)).toEqual(['weapon1'])
|
||
})
|
||
|
||
it('resolves Shields strictly to weapon2 offhand', () => {
|
||
const shield = createTestItem({ base: { id: 'buc', kind: 'armor', tags: ['shld'] } })
|
||
expect(resolveAllowedSlots(shield.base, dropTables)).toEqual(['weapon2'])
|
||
})
|
||
|
||
it('resolves Quivers strictly to weapon2 offhand', () => {
|
||
const quiver = createTestItem({ base: { id: 'aqv', kind: 'misc', tags: ['bowq'] } })
|
||
expect(resolveAllowedSlots(quiver.base, dropTables)).toEqual(['weapon2'])
|
||
})
|
||
|
||
it('resolves Non-equippable items (Runes, Gems, Potions, Charms) to empty array', () => {
|
||
const rune = createTestItem({ base: { id: 'r01', kind: 'misc', tags: ['rune'] } })
|
||
const potion = createTestItem({ base: { id: 'hp1', kind: 'misc', tags: ['potion'] } })
|
||
const gem = createTestItem({ base: { id: 'gsv', kind: 'misc', tags: ['gem'] } })
|
||
const charm = createTestItem({ base: { id: 'cm1', kind: 'misc', tags: ['charm'] } })
|
||
|
||
expect(resolveAllowedSlots(rune.base, dropTables)).toEqual([])
|
||
expect(resolveAllowedSlots(potion.base, dropTables)).toEqual([])
|
||
expect(resolveAllowedSlots(gem.base, dropTables)).toEqual([])
|
||
expect(resolveAllowedSlots(charm.base, dropTables)).toEqual([])
|
||
})
|
||
})
|
||
|
||
describe('4. Tooltip Stats & Equipment Attributes Formatting', () => {
|
||
it('populates defense, damage, requirements, durability and stats lines', () => {
|
||
const weapon = createTestItem({
|
||
base: { id: 'swd', name: 'Short Sword', kind: 'weapon', damage: 6, invWidth: 1, invHeight: 2, level: 1 },
|
||
durability: 24,
|
||
maxDurability: 24,
|
||
rolledProps: [
|
||
{ code: 'dmg-min', min: 2, max: 2, value: 2 },
|
||
{ code: 'dmg-max', min: 4, max: 4, value: 4 },
|
||
],
|
||
})
|
||
|
||
const uiWeapon = itemToUiInventoryItem(weapon, dropTables)
|
||
expect(uiWeapon.damage).toBeDefined()
|
||
expect(uiWeapon.durability).toEqual({ current: 24, max: 24 })
|
||
expect(uiWeapon.stats.length).toBeGreaterThan(0)
|
||
expect(uiWeapon.stats.some(s => s.text.includes('伤害') || s.text.includes('Damage') || s.text.includes('+'))).toBe(true)
|
||
})
|
||
})
|
||
|
||
describe('5. Normal and Telekinesis Ground Item Pickup Loop Conversion', () => {
|
||
let mockCanvas: any
|
||
let mockStatus: { textContent: string }
|
||
|
||
beforeEach(() => {
|
||
mockStatus = { textContent: '' }
|
||
mockCanvas = {
|
||
getBoundingClientRect: () => ({ left: 0, top: 0, width: 800, height: 600 }),
|
||
addEventListener: vi.fn(),
|
||
removeEventListener: vi.fn(),
|
||
}
|
||
})
|
||
|
||
function createTestEngine() {
|
||
const terrain = { widthPx: 1000, heightPx: 1000, overlap: () => 0 }
|
||
const engine = new GameEngine(terrain, {
|
||
spawn: { x: 500, y: 500 },
|
||
stats: [],
|
||
xpTable: DEMO_EXPERIENCE,
|
||
skills: DEMO_SKILLS,
|
||
questDefs: DEMO_QUESTS,
|
||
combatOptions: {
|
||
playerSpeed: 4,
|
||
playerReach: 50,
|
||
playerCooldownTicks: 10,
|
||
playerDamage: 5,
|
||
playerManaPerAttack: 1,
|
||
respawnTicks: 100,
|
||
},
|
||
talkRadius: 48,
|
||
pickupRadius: 48,
|
||
inventoryCols: 10,
|
||
inventoryRows: 4,
|
||
lootSeed: 1234,
|
||
npcDefs: [],
|
||
})
|
||
engine.setDropTables(dropTables)
|
||
return engine
|
||
}
|
||
|
||
it('converts raw ground item into UiInventoryItem upon normal pickup and places into inventory grid', () => {
|
||
const engine = createTestEngine()
|
||
const invPanel = new InventoryPanel()
|
||
invPanel.gridItems = []
|
||
const hudManager: any = {
|
||
inventory: invPanel,
|
||
syncPublishedState: vi.fn(),
|
||
}
|
||
|
||
const rawItem = createTestItem({
|
||
base: { id: 'hax', name: 'Hand Axe', kind: 'weapon', damage: 6, invWidth: 1, invHeight: 2 },
|
||
})
|
||
const dropped = engine.dropItem(rawItem as any, 510, 500)
|
||
|
||
const controller = new SceneMouseController({
|
||
canvas: mockCanvas,
|
||
engine,
|
||
camera: { zoom: 1 } as any,
|
||
input: { takeLeftClick: () => null, takeRightClick: () => null, shiftHeld: false, movement: () => ({ x: 0, y: 0 }) } as any,
|
||
getRuntime: () => ({ grid: { cellsX: 50, cellsY: 50 }, waypoints: [], stashes: [] }) as any,
|
||
hudManager,
|
||
waypointNetwork: null as any,
|
||
status: mockStatus as any,
|
||
playerAnimator: { play: vi.fn(), update: vi.fn(), currentFrame: null } as any,
|
||
getCharacter: () => null,
|
||
})
|
||
|
||
controller.pickupGroundItem(dropped)
|
||
|
||
// Item should be picked up into invPanel.gridItems
|
||
expect(invPanel.gridItems.length).toBe(1)
|
||
const placedItem = invPanel.gridItems[0]!.item
|
||
expect(placedItem.allowedSlots).toEqual(['weapon1', 'weapon2'])
|
||
expect(placedItem.invFile).toBe('invhax')
|
||
expect(placedItem.nameZh).toContain('斧')
|
||
expect(hudManager.syncPublishedState).toHaveBeenCalled()
|
||
expect(engine.groundItems.count).toBe(0)
|
||
})
|
||
|
||
it('converts raw ground item into UiInventoryItem upon Telekinesis pickup', () => {
|
||
const engine = createTestEngine()
|
||
const invPanel = new InventoryPanel()
|
||
invPanel.gridItems = []
|
||
const hudManager: any = {
|
||
inventory: invPanel,
|
||
syncPublishedState: vi.fn(),
|
||
getSkillLevel: () => 1,
|
||
hotkeys: { availableSkills: [{ skillId: 43, manaCost: 0 }] },
|
||
}
|
||
|
||
const rawPotion = createTestItem({
|
||
base: { id: 'hp1', name: 'Minor Healing Potion', kind: 'misc', invWidth: 1, invHeight: 1 },
|
||
})
|
||
const dropped = engine.dropItem(rawPotion as any, 520, 500)
|
||
|
||
const handled = castSkill(43, dropped.x, dropped.y, {
|
||
engine,
|
||
runtime: { waypoints: [], stashes: [] } as any,
|
||
hudManager,
|
||
status: mockStatus as any,
|
||
})
|
||
|
||
expect(handled).toBe(true)
|
||
expect(invPanel.gridItems.length).toBe(1)
|
||
const placedPotion = invPanel.gridItems[0]!.item
|
||
expect(placedPotion.invFile).toBe('invhp1')
|
||
expect(placedPotion.nameZh).toContain('轻微治疗药剂')
|
||
expect(engine.groundItems.count).toBe(0)
|
||
})
|
||
})
|
||
|
||
describe('6. Gold Accumulation & PLAYER_GOLD_CAP Parity', () => {
|
||
let mockCanvas: any
|
||
let mockStatus: { textContent: string }
|
||
|
||
beforeEach(() => {
|
||
mockStatus = { textContent: '' }
|
||
mockCanvas = {
|
||
getBoundingClientRect: () => ({ left: 0, top: 0, width: 800, height: 600 }),
|
||
addEventListener: vi.fn(),
|
||
removeEventListener: vi.fn(),
|
||
}
|
||
})
|
||
|
||
function createTestEngine() {
|
||
const terrain = { widthPx: 1000, heightPx: 1000, overlap: () => 0 }
|
||
return new GameEngine(terrain, {
|
||
spawn: { x: 500, y: 500 },
|
||
stats: [],
|
||
xpTable: DEMO_EXPERIENCE,
|
||
skills: DEMO_SKILLS,
|
||
questDefs: DEMO_QUESTS,
|
||
combatOptions: {
|
||
playerSpeed: 4,
|
||
playerReach: 50,
|
||
playerCooldownTicks: 10,
|
||
playerDamage: 5,
|
||
playerManaPerAttack: 1,
|
||
respawnTicks: 100,
|
||
},
|
||
talkRadius: 48,
|
||
pickupRadius: 48,
|
||
inventoryCols: 10,
|
||
inventoryRows: 4,
|
||
lootSeed: 1234,
|
||
npcDefs: [],
|
||
})
|
||
}
|
||
|
||
it('accumulates gold and clamps to authentic inventory gold capacity on normal pickup', () => {
|
||
const engine = createTestEngine()
|
||
engine.world.player.level = 10
|
||
engine.bag.gold = 95_000
|
||
const invPanel = new InventoryPanel()
|
||
invPanel.playerLevel = 10
|
||
invPanel.gold = 95_000
|
||
const hudManager: any = {
|
||
inventory: invPanel,
|
||
syncPublishedState: vi.fn(),
|
||
}
|
||
|
||
// Drop 10,000 gold pile
|
||
const goldDrop = engine.dropGold(10_000, 510, 500)
|
||
expect(goldDrop).not.toBeNull()
|
||
|
||
const controller = new SceneMouseController({
|
||
canvas: mockCanvas,
|
||
engine,
|
||
camera: { zoom: 1 } as any,
|
||
input: { takeLeftClick: () => null, takeRightClick: () => null, shiftHeld: false, movement: () => ({ x: 0, y: 0 }) } as any,
|
||
getRuntime: () => ({ grid: { cellsX: 50, cellsY: 50 }, waypoints: [], stashes: [] }) as any,
|
||
hudManager,
|
||
waypointNetwork: null as any,
|
||
status: mockStatus as any,
|
||
playerAnimator: { play: vi.fn(), update: vi.fn(), currentFrame: null } as any,
|
||
getCharacter: () => null,
|
||
})
|
||
|
||
controller.pickupGroundItem(goldDrop!)
|
||
|
||
// 95,000 + 5,000 clamped to 100,000
|
||
expect(invPanel.gold).toBe(100_000)
|
||
expect(hudManager.syncPublishedState).toHaveBeenCalled()
|
||
expect(mockStatus.textContent).toContain('拾起金币:5000')
|
||
})
|
||
|
||
it('accumulates gold and clamps to authentic inventory gold capacity on Telekinesis pickup', () => {
|
||
const engine = createTestEngine()
|
||
engine.world.player.level = 10
|
||
engine.bag.gold = 90_000
|
||
const invPanel = new InventoryPanel()
|
||
invPanel.playerLevel = 10
|
||
invPanel.gold = 90_000
|
||
const hudManager: any = {
|
||
inventory: invPanel,
|
||
syncPublishedState: vi.fn(),
|
||
getSkillLevel: () => 1,
|
||
hotkeys: { availableSkills: [{ skillId: 43, manaCost: 0 }] },
|
||
}
|
||
|
||
const goldDrop = engine.dropGold(20_000, 520, 500)
|
||
expect(goldDrop).not.toBeNull()
|
||
|
||
castSkill(43, goldDrop!.x, goldDrop!.y, {
|
||
engine,
|
||
runtime: { waypoints: [], stashes: [] } as any,
|
||
hudManager,
|
||
status: mockStatus as any,
|
||
})
|
||
|
||
expect(invPanel.gold).toBe(100_000)
|
||
expect(hudManager.syncPublishedState).toHaveBeenCalled()
|
||
})
|
||
})
|
||
|
||
describe('7. Inventory Full Refusal & Parabolic Flippy Bounce', () => {
|
||
let mockCanvas: any
|
||
let mockStatus: { textContent: string }
|
||
|
||
beforeEach(() => {
|
||
mockStatus = { textContent: '' }
|
||
mockCanvas = {
|
||
getBoundingClientRect: () => ({ left: 0, top: 0, width: 800, height: 600 }),
|
||
addEventListener: vi.fn(),
|
||
removeEventListener: vi.fn(),
|
||
}
|
||
})
|
||
|
||
it('refuses pickup when 10x4 grid has no free space, triggering bounce and refusal feedback', () => {
|
||
const terrain = { widthPx: 1000, heightPx: 1000, overlap: () => 0 }
|
||
const engine = new GameEngine(terrain, {
|
||
spawn: { x: 500, y: 500 },
|
||
stats: [],
|
||
xpTable: DEMO_EXPERIENCE,
|
||
skills: DEMO_SKILLS,
|
||
questDefs: DEMO_QUESTS,
|
||
combatOptions: {
|
||
playerSpeed: 4,
|
||
playerReach: 50,
|
||
playerCooldownTicks: 10,
|
||
playerDamage: 5,
|
||
playerManaPerAttack: 1,
|
||
respawnTicks: 100,
|
||
},
|
||
talkRadius: 48,
|
||
pickupRadius: 48,
|
||
inventoryCols: 10,
|
||
inventoryRows: 4,
|
||
lootSeed: 1234,
|
||
npcDefs: [],
|
||
})
|
||
engine.setDropTables(dropTables)
|
||
|
||
const invPanel = new InventoryPanel()
|
||
invPanel.gridItems = []
|
||
// Completely fill the 10x4 inventory grid with 2x2 items (10 items = 40 cells)
|
||
for (let c = 0; c < 10; c += 2) {
|
||
for (let r = 0; r < 4; r += 2) {
|
||
invPanel.gridItems.push({
|
||
item: {
|
||
id: `filler-${c}-${r}`,
|
||
code: 'cap',
|
||
invFile: 'invcap',
|
||
name: 'Cap',
|
||
nameZh: '帽子 (Cap)',
|
||
baseNameZh: '帽子 (Cap)',
|
||
quality: 'normal',
|
||
invWidth: 2,
|
||
invHeight: 2,
|
||
allowedSlots: ['helm'],
|
||
stats: [],
|
||
},
|
||
col: c,
|
||
row: r,
|
||
})
|
||
}
|
||
}
|
||
expect(invPanel.gridItems.length).toBe(10) // 10 * 4 = 40 cells, 100% full
|
||
|
||
const dropped = engine.dropItem(
|
||
createTestItem({
|
||
base: { id: 'hax', name: 'Hand Axe', kind: 'weapon', invWidth: 1, invHeight: 2 },
|
||
}) as any,
|
||
510,
|
||
500,
|
||
)
|
||
|
||
const hudManager: any = {
|
||
inventory: invPanel,
|
||
syncPublishedState: vi.fn(),
|
||
}
|
||
|
||
const controller = new SceneMouseController({
|
||
canvas: mockCanvas,
|
||
engine,
|
||
camera: { zoom: 1 } as any,
|
||
input: { takeLeftClick: () => null, takeRightClick: () => null, shiftHeld: false, movement: () => ({ x: 0, y: 0 }) } as any,
|
||
getRuntime: () => ({ grid: { cellsX: 50, cellsY: 50 }, waypoints: [], stashes: [] }) as any,
|
||
hudManager,
|
||
waypointNetwork: null as any,
|
||
status: mockStatus as any,
|
||
playerAnimator: { play: vi.fn(), update: vi.fn(), currentFrame: null } as any,
|
||
getCharacter: () => null,
|
||
})
|
||
|
||
const feedbackSpy = vi.spyOn(controller, 'playInventoryFullFeedback')
|
||
const notificationSpy = vi.spyOn(controller, 'showNotification')
|
||
|
||
controller.pickupGroundItem(dropped)
|
||
|
||
// Item refused
|
||
expect(engine.metrics.inventoryRefusals).toBe(1)
|
||
expect(engine.groundItems.count).toBe(1)
|
||
expect(dropped.bounceState).toBeDefined()
|
||
expect(dropped.bounceState!.phase).toBe('primary')
|
||
expect(dropped.bounceState!.durationMs).toBe(350)
|
||
expect(dropped.bounceState!.peakHeightPx).toBe(28)
|
||
expect(feedbackSpy).toHaveBeenCalled()
|
||
expect(notificationSpy).toHaveBeenCalledWith('包裹已满。')
|
||
expect(mockStatus.textContent).toBe('包裹已满。')
|
||
})
|
||
})
|
||
})
|