diablo2-web/tests/item-to-ui-bridge.test.ts

672 lines
26 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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('包裹已满。')
})
})
})