From b5754c0197f0e25ffd10fadc4986e60c3d4044a4 Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Thu, 24 Sep 2026 10:20:41 +0000 Subject: [PATCH] feat(ui): drop item to ui inventory item bridge, bilingual tooltip, and pickup loop (Fixes #407) --- src/game/engine.ts | 4 + src/scene/act-scene.ts | 17 +- src/ui/inventory.ts | 2 + src/ui/item-bridge.ts | 468 ++++++++++++++++++++++ tests/item-to-ui-bridge.test.ts | 665 ++++++++++++++++++++++++++++++++ 5 files changed, 1151 insertions(+), 5 deletions(-) create mode 100644 src/ui/item-bridge.ts create mode 100644 tests/item-to-ui-bridge.test.ts diff --git a/src/game/engine.ts b/src/game/engine.ts index 6250b26..f03bd5e 100644 --- a/src/game/engine.ts +++ b/src/game/engine.ts @@ -323,6 +323,10 @@ export class GameEngine { this.opts.monsterKinds = dropTables.monsterKinds } + get dropTables(): DropTables | undefined { + return this.opts.dropTables + } + /** * Activates or deactivates an authentic Diablo II v1.13c aura on the player. * If skillId is an aura skill (e.g. Conviction 123, Holy Freeze 114, Defiance 104), diff --git a/src/scene/act-scene.ts b/src/scene/act-scene.ts index a6f2bf1..8d96862 100644 --- a/src/scene/act-scene.ts +++ b/src/scene/act-scene.ts @@ -55,7 +55,12 @@ import { resolveLadderCollisions, } from '../ui/ground-labels.ts' import { BAKED_UI_MANIFEST } from '../ui/baked-ui-meta.ts' -import { resolveItemSpriteRect, resolveGroundItemSpriteRect } from '../ui/inventory.ts' +import { + resolveItemSpriteRect, + resolveGroundItemSpriteRect, + itemToUiInventoryItem, + PLAYER_GOLD_CAP, +} from '../ui/inventory.ts' import type { AtlasFrame } from '../render/atlas.ts' import { SpriteRenderer } from '../render/renderer.ts' import type { AtlasHandle } from '../render/renderer.ts' @@ -1569,13 +1574,14 @@ export function castSkill( if (nearbyGroundItem.isGold) { const res = engine.pickupGold(nearbyGroundItem.id) if (res.success && hudManager) { - hudManager.inventory.gold += res.amount + hudManager.inventory.gold = Math.min(PLAYER_GOLD_CAP, hudManager.inventory.gold + res.amount) hudManager.syncPublishedState() } status.textContent = `心灵传动拾取金币` } else { if (hudManager) { - const placed = hudManager.inventory.autoPlaceInGrid(nearbyGroundItem.item) + const uiItem = itemToUiInventoryItem(nearbyGroundItem.item, engine.dropTables) + const placed = hudManager.inventory.autoPlaceInGrid(uiItem) if (!placed) { engine.metrics.inventoryRefusals += 1 triggerFlippyBounce(nearbyGroundItem, typeof performance !== 'undefined' ? performance.now() : Date.now()) @@ -2851,14 +2857,15 @@ export class SceneMouseController { const res = this.engine.pickupGold(item.id) if (res.success) { if (this.hudManager) { - this.hudManager.inventory.gold += res.amount + this.hudManager.inventory.gold = Math.min(PLAYER_GOLD_CAP, this.hudManager.inventory.gold + res.amount) this.hudManager.syncPublishedState() } this.status.textContent = `拾起金币:${res.amount}` } } else { if (this.hudManager) { - const placed = this.hudManager.inventory.autoPlaceInGrid(item.item) + const uiItem = itemToUiInventoryItem(item.item, this.engine.dropTables) + const placed = this.hudManager.inventory.autoPlaceInGrid(uiItem) if (!placed) { this.engine.metrics.inventoryRefusals += 1 triggerFlippyBounce(item, typeof performance !== 'undefined' ? performance.now() : Date.now()) diff --git a/src/ui/inventory.ts b/src/ui/inventory.ts index 8b745b0..4d646c3 100644 --- a/src/ui/inventory.ts +++ b/src/ui/inventory.ts @@ -15,6 +15,8 @@ import type { D2ColorCode, D2FontRenderer } from './font.ts' import { BAKED_UI_MANIFEST, type SpriteRect } from './baked-ui-meta.ts' export type { SpriteRect } +export { itemToUiInventoryItem, resolveAllowedSlots, resolveItemInvFile } from './item-bridge.ts' +export { PLAYER_GOLD_CAP } from './world-panels.ts' export type EquipSlotId = | 'helm' diff --git a/src/ui/item-bridge.ts b/src/ui/item-bridge.ts new file mode 100644 index 0000000..f4226cb --- /dev/null +++ b/src/ui/item-bridge.ts @@ -0,0 +1,468 @@ +/** + * Diablo II v1.13c Drop Item to UiInventoryItem Bridge. + * + * Implements authoritative bridging from concrete `Item` (produced by drop pipeline) + * to `UiInventoryItem` (consumed by UI Inventory Paperdoll and Canvas rendering): + * - 1.13c invFile resolution hierarchy matching BAKED_UI_MANIFEST.itemRects 100% + * - BodyLoc1/BodyLoc2 equipment slot mapping with category inheritance + * - Bilingual item names (nameZh, baseNameZh) across all quality tiers + * - Quality tier normalization ('normal' | 'magic' | 'rare' | 'set' | 'unique' | 'craft' | 'rune') + * - Rich tooltip attributes & rolled property formatting with D2ColorCode lines + */ + +import type { Item, ItemBase } from '../game/items.ts' +import { ItemQuality } from '../game/items.ts' +import type { DropTables } from '../game/drop-pipeline.ts' +import { + type UiInventoryItem, + type EquipSlotId, + type UiItemQuality, +} from './inventory.ts' +import { BAKED_UI_MANIFEST } from './baked-ui-meta.ts' +import { formatItemTooltip } from '../game/item-tooltip.ts' +import { translateItemName, translateRareName, translateMagicName } from '../game/tooltip-i18n.ts' +import { isRuneCode } from '../game/gems-data.ts' +import type { D2ColorCode } from './font.ts' +import { isA } from '../game/item-types.ts' + +/** + * Resolves the allowable paperdoll equipment slot(s) for an item base based on + * Diablo II v1.13c `ItemTypes.txt` body locations and category inheritance. + */ +export function resolveAllowedSlots( + base: { + readonly id?: string | undefined + readonly type?: string | undefined + readonly kind?: string | undefined + readonly tags?: readonly string[] | undefined + readonly twoHanded?: boolean | undefined + readonly oneOrTwoHanded?: boolean | undefined + }, + dropTables?: DropTables, +): readonly EquipSlotId[] { + const code = (base.id ?? '').trim().toLowerCase() + const typeCode = (base.type ?? '').trim().toLowerCase() + const tags = base.tags ?? [] + + // Check ItemTypes definition if dropTables is provided + if (dropTables?.itemTypes && typeCode) { + const def = dropTables.itemTypes.byCode.get(typeCode) + if (def && !def.body) { + return [] + } + if (def && def.bodyLoc1 && def.bodyLoc1 === def.bodyLoc2) { + switch (def.bodyLoc1) { + case 'head': return ['helm'] + case 'neck': return ['amulet'] + case 'tors': return ['armor'] + case 'glov': return ['gloves'] + case 'feet': return ['boots'] + case 'belt': return ['belt'] + } + } + if (def && def.bodyLoc1 === 'rrin' && def.bodyLoc2 === 'lrin') { + return ['ring1', 'ring2'] + } + } + + const isAPred = (target: string): boolean => { + if (!typeCode) return false + if (typeCode === target) return true + if (dropTables?.isA) { + try { + if (dropTables.isA(typeCode, target)) return true + } catch { + // ignore + } + } + if (dropTables?.itemTypes) { + try { + if (isA(typeCode, target, dropTables.itemTypes)) return true + } catch { + // ignore + } + } + return false + } + + // Helm + if ( + tags.some(t => ['helm', 'phlm', 'circ', 'pelt'].includes(t)) || + ['helm', 'circ', 'phlm', 'pelt', 'cap', 'hlm', 'skp', 'ghm', 'crn', 'msk'].includes(typeCode) || + isAPred('helm') + ) { + return ['helm'] + } + + // Amulet + if (tags.includes('amul') || typeCode === 'amul' || code === 'amu' || isAPred('amul')) { + return ['amulet'] + } + + // Ring + if (tags.includes('ring') || typeCode === 'ring' || code === 'rin' || isAPred('ring')) { + return ['ring1', 'ring2'] + } + + // Gloves + if (tags.includes('glov') || typeCode === 'glov' || isAPred('glov') || ['lgl', 'vgl', 'mgl', 'tgl', 'hgl'].includes(code)) { + return ['gloves'] + } + + // Boots + if (tags.includes('boot') || typeCode === 'boot' || isAPred('boot') || ['lbt', 'vbt', 'mbt', 'tbt', 'hbt'].includes(code)) { + return ['boots'] + } + + // Belt + if (tags.includes('belt') || typeCode === 'belt' || isAPred('belt') || ['lbl', 'vbl', 'mbl', 'tbl', 'hbl'].includes(code)) { + return ['belt'] + } + + // Shields & Off-hand (strictly weapon2) + if ( + tags.some(t => ['shld', 'ashd', 'head', 'seco'].includes(t)) || + ['shie', 'ashd', 'head', 'shld'].includes(typeCode) || + isAPred('shld') || + isAPred('seco') + ) { + return ['weapon2'] + } + + // Quivers (strictly weapon2) + if ( + tags.some(t => ['bowq', 'xboq', 'mboq', 'mxbq'].includes(t)) || + ['bowq', 'xboq', 'mboq', 'mxbq'].includes(typeCode) || + isAPred('bowq') || + isAPred('xboq') + ) { + return ['weapon2'] + } + + // Armor / Torso + if ( + base.kind === 'armor' || + tags.some(t => ['tors', 'cloa'].includes(t)) || + ['tors', 'cloa'].includes(typeCode) || + isAPred('tors') + ) { + return ['armor'] + } + + // Weapons + if (base.kind === 'weapon' || tags.includes('weap') || isAPred('weap')) { + const isTwoHanded = Boolean( + base.twoHanded || + tags.some(t => ['bow', 'xbow', 'staf', 'pole', 'spea', '2h'].includes(t)) || + ['bow', 'xbow', 'staf', 'pole', 'spea', '2h'].includes(typeCode) || + (dropTables?.weapons?.get?.(code) as any)?.twohanded === '1' + ) + if (isTwoHanded) { + return ['weapon1'] + } + return ['weapon1', 'weapon2'] + } + + return [] +} + +/** + * Resolves the DC6 inventory graphic sprite key (`invFile`) following + * the authentic Diablo II 1.13c resolution hierarchy and guaranteeing + * 100% resolution against `BAKED_UI_MANIFEST.itemRects`. + */ +export function resolveItemInvFile( + item: Item, + quality: UiItemQuality, + code: string, + dropTables?: DropTables, +): { invFile: string; invtransform?: string | undefined; chrtransform?: string | undefined } { + let invFile: string | undefined = undefined + let invtransform: string | undefined = item.uniqueItemDef?.invtransform ?? item.setItemDef?.invtransform + const chrtransform: string | undefined = item.uniqueItemDef?.chrtransform ?? item.setItemDef?.chrtransform + + // 1. Explicit Unique / Set Table Graphics + if (quality === 'unique') { + if (item.uniqueItemDef?.invfile) { + invFile = item.uniqueItemDef.invfile + } else if (dropTables) { + const b = dropTables.weapons.get(code) ?? dropTables.armor.get(code) ?? dropTables.misc.get(code) + if ((b as any)?.uniqueinvfile) { + invFile = (b as any).uniqueinvfile + } + } + } else if (quality === 'set') { + if (item.setItemDef?.invfile) { + invFile = item.setItemDef.invfile + } else if (dropTables) { + const b = dropTables.weapons.get(code) ?? dropTables.armor.get(code) ?? dropTables.misc.get(code) + if ((b as any)?.setinvfile) { + invFile = (b as any).setinvfile + } + } + } + + // 2. Pre-baked Manifest Lookup by Item Display Name (handles dyed unique/set sprites e.g. "invcap_cgrn") + if (!invFile && item.name && BAKED_UI_MANIFEST.codeToInvFile[item.name]) { + const manifestInv = BAKED_UI_MANIFEST.codeToInvFile[item.name] + if (manifestInv.includes('_')) { + const [baseKey, dyeKey] = manifestInv.split('_') + invFile = baseKey + if (!invtransform) invtransform = dyeKey + } else { + invFile = manifestInv + } + } + + // 3. Base Item Definition Graphic + if (!invFile) { + const baseInv = (item.base as any)?.invfile ?? (item.base as any)?.invFile + if (baseInv) { + invFile = baseInv + } else if (dropTables) { + const b = dropTables.weapons.get(code) ?? dropTables.armor.get(code) ?? dropTables.misc.get(code) + if ((b as any)?.invfile) { + invFile = (b as any).invfile + } + } + } + + // 4. Base Code Lookup in Manifest + if (!invFile && BAKED_UI_MANIFEST.codeToInvFile[code]) { + invFile = BAKED_UI_MANIFEST.codeToInvFile[code] + } + + // 5. Direct Prefix Fallback + if (!invFile) { + invFile = 'inv' + code + } + + // 6. Authentic Charms, Rings, Amulets, Jewels & Quest Item Overrides + if (code === 'cm1' && (!invFile || invFile === 'invchm')) invFile = 'invch1' + if (code === 'cm2' && (!invFile || invFile === 'invwnd')) invFile = 'invch2' + if (code === 'cm3' && (!invFile || invFile === 'invsst')) invFile = 'invch3' + if ((code === 'rin' || code === 'ring') && (!invFile || invFile === 'invrin')) invFile = 'invrin1' + if ((code === 'amu' || code === 'amul') && (!invFile || invFile === 'invamu')) invFile = 'invamu1' + if ((code === 'jew' || code === 'jewl') && (!invFile || invFile === 'invjew' || invFile === 'invjw')) invFile = 'invjw1' + if (code === 'tbk') invFile = 'invbbk' + if (code === 'ibk') invFile = 'invrbk' + if (code === 'box' || code === 'cube') invFile = 'invbox' + + // 7. Verify 100% Atlas Matching Against BAKED_UI_MANIFEST.itemRects + if (invFile && BAKED_UI_MANIFEST.itemRects[invFile]) { + // verified exact match + } else if (invFile && BAKED_UI_MANIFEST.itemRects[invFile.toLowerCase()]) { + invFile = invFile.toLowerCase() + } else { + // Find closest valid atlas rect + if (item.name && BAKED_UI_MANIFEST.codeToInvFile[item.name] && BAKED_UI_MANIFEST.itemRects[BAKED_UI_MANIFEST.codeToInvFile[item.name]]) { + invFile = BAKED_UI_MANIFEST.codeToInvFile[item.name] + } else if (BAKED_UI_MANIFEST.codeToInvFile[code] && BAKED_UI_MANIFEST.itemRects[BAKED_UI_MANIFEST.codeToInvFile[code]]) { + invFile = BAKED_UI_MANIFEST.codeToInvFile[code] + } else { + const w = item.invWidth ?? item.base?.invWidth ?? 1 + const h = item.invHeight ?? item.base?.invHeight ?? 1 + if (item.base?.kind === 'weapon') { + invFile = 'invhax' + } else if (item.base?.kind === 'armor') { + invFile = 'invcap' + } else if (w === 1 && h === 1) { + invFile = 'invch1' + } else if (w === 1 && h === 2) { + invFile = 'invch2' + } else if (w === 1 && h === 3) { + invFile = 'invch3' + } else { + invFile = 'invcap' + } + } + } + + return { invFile, invtransform, chrtransform } +} + +/** + * Converts a game engine `Item` instance into a `UiInventoryItem` instance + * suitable for the UI Inventory grid, paperdoll slots, and hover tooltips. + * + * Guarantees Diablo II v1.13c parity: + * - 100% valid `invFile` resolving to `BAKED_UI_MANIFEST.itemRects` + * - Accurate `allowedSlots` for equipment placement + * - Bilingual names `nameZh` and `baseNameZh` + * - Standardized `UiItemQuality` + * - Formatted stats and requirement lines with authentic D2ColorCode colors + */ +export function itemToUiInventoryItem( + item: Item, + dropTables?: DropTables, +): UiInventoryItem { + // Defensive passthrough: already a complete UiInventoryItem + if ( + item && + Array.isArray((item as any).allowedSlots) && + typeof (item as any).invFile === 'string' && + typeof (item as any).nameZh === 'string' + ) { + return item as unknown as UiInventoryItem + } + + // Handle case where ground entity was passed instead of ground entity.item + const rawItem: Item = (item as any)?.item && typeof (item as any)?.item === 'object' ? (item as any).item : item + const base = rawItem.base ?? { id: 'unknown', name: rawItem.name ?? 'Item', kind: 'misc', invWidth: 1, invHeight: 1, maxStack: 1, value: 0, damage: 0, defense: 0, tags: [], level: 1 } + const code = (rawItem.code ?? base.id ?? '').trim().toLowerCase() + + // 1. Quality Mapping + let quality: UiItemQuality = 'normal' + const rawQ = rawItem.quality + const rawRarity = String(rawItem.rarity ?? '').toLowerCase() + + if (isRuneCode(code) || (base as any)?.isRune || (rawItem as any)?.isRune || rawRarity === 'rune') { + quality = 'rune' + } else if (rawQ === ItemQuality.UNIQUE || rawQ === 7 || rawRarity === 'unique' || Boolean(rawItem.uniqueItemDef)) { + quality = 'unique' + } else if (rawQ === ItemQuality.SET || rawQ === 5 || rawRarity === 'set' || Boolean(rawItem.setItemDef)) { + quality = 'set' + } else if (rawQ === ItemQuality.RARE || rawQ === 6 || rawRarity === 'rare' || Boolean(rawItem.rolledRareAffixes)) { + quality = 'rare' + } else if (rawQ === ItemQuality.MAGIC || rawQ === 4 || rawRarity === 'magic' || Boolean(rawItem.rolledMagicAffixes)) { + quality = 'magic' + } else if (rawQ === ItemQuality.CRAFTED || rawQ === 8 || rawRarity === 'crafted' || rawRarity === 'craft') { + quality = 'craft' + } else if (typeof rawQ === 'string' && ['normal', 'magic', 'rare', 'set', 'unique', 'craft', 'rune'].includes(rawQ)) { + quality = rawQ as UiItemQuality + } + + // 2. Bilingual Name & Subtitle Construction + let nameZh: string + let baseNameZh: string + + const baseEn = base.name || rawItem.name || 'Item' + const rawBaseZh = translateItemName(baseEn) + const formattedBaseZh = rawBaseZh && rawBaseZh !== baseEn ? `${rawBaseZh} (${baseEn})` : baseEn + + if (quality === 'unique' && rawItem.uniqueItemDef) { + const uEn = rawItem.uniqueItemDef.index || rawItem.name + const uZh = translateItemName(uEn) + nameZh = uZh && uZh !== uEn ? `${uZh} (${uEn})` : uEn + baseNameZh = formattedBaseZh + } else if (quality === 'set' && rawItem.setItemDef) { + const sEn = rawItem.setItemDef.index || rawItem.name + const sZh = translateItemName(sEn) + const setEn = rawItem.setItemDef.set + const setZh = setEn ? translateItemName(setEn) : '' + nameZh = sZh && sZh !== sEn ? `${sZh} (${sEn})` : sEn + baseNameZh = setEn && setZh && setZh !== setEn + ? `${formattedBaseZh} · [${setZh} (${setEn})]` + : formattedBaseZh + } else if (quality === 'rare' && rawItem.rolledRareAffixes) { + const rEn = rawItem.rolledRareAffixes.name || rawItem.name + const rZh = translateRareName(rEn) + nameZh = rZh && rZh !== rEn ? `${rZh} (${rEn})` : rEn + baseNameZh = formattedBaseZh + } else if (quality === 'magic' && rawItem.rolledMagicAffixes) { + const mEn = rawItem.rolledMagicAffixes.name || rawItem.name + const mZh = translateMagicName(mEn, baseEn) + nameZh = mZh && mZh !== mEn ? `${mZh} (${mEn})` : mEn + baseNameZh = formattedBaseZh + } else { + const itemEn = rawItem.name || baseEn + const itemZh = translateItemName(itemEn) + nameZh = itemZh && itemZh !== itemEn ? `${itemZh} (${itemEn})` : itemEn + baseNameZh = formattedBaseZh + } + + // Use pre-localized overrides if present + if ((rawItem as any).nameZh) { + nameZh = (rawItem as any).nameZh + } + if ((rawItem as any).baseNameZh) { + baseNameZh = (rawItem as any).baseNameZh + } + + // 3. invFile & Dye Resolution + const { invFile, invtransform, chrtransform } = resolveItemInvFile(rawItem, quality, code, dropTables) + + // 4. Allowed Equipment Slots + const allowedSlots = resolveAllowedSlots( + { + id: code, + type: (base as any).type, + kind: base.kind, + tags: base.tags, + twoHanded: (base as any).twoHanded, + oneOrTwoHanded: (base as any).oneOrTwoHanded, + }, + dropTables, + ) + + // 5. Tooltip & Formatted Stats + const tt = formatItemTooltip(rawItem, 'zh') + let stats: { text: string; color?: D2ColorCode }[] = (tt.lines || []).map(l => ({ + text: l.text, + color: (l.color === 'instruction' ? 'white' : l.color) as D2ColorCode, + })) + + // Fallback for items with flat stats dictionary but no rolled props + if (stats.length === 0 && rawItem.stats && typeof rawItem.stats === 'object') { + const fallbackStats = Object.entries(rawItem.stats) + .filter(([k, v]) => typeof v === 'number' && v !== 0 && k !== 'gold' && k !== 'value') + .map(([k, v]) => ({ + text: `+${v} ${k}`, + color: 'blue' as D2ColorCode, + })) + if (fallbackStats.length > 0) { + stats = fallbackStats + } + } + + // Damage evaluation + let damage: string | undefined = undefined + if (tt.oneHandDamage && tt.twoHandDamage) { + damage = `${tt.oneHandDamage.min} - ${tt.oneHandDamage.max} / 双手: ${tt.twoHandDamage.min} - ${tt.twoHandDamage.max}` + } else if (tt.twoHandDamage) { + damage = `双手伤害: ${tt.twoHandDamage.min} - ${tt.twoHandDamage.max}` + } else if (tt.oneHandDamage) { + damage = `${tt.oneHandDamage.min} - ${tt.oneHandDamage.max}` + } else if (base.kind === 'weapon' && (base as any).damage !== undefined && (base as any).damage > 0) { + damage = String((base as any).damage) + } + + // Durability evaluation + const durability = tt.durability + ? { current: tt.durability.current, max: tt.durability.max } + : rawItem.durability !== undefined && rawItem.maxDurability !== undefined + ? { current: rawItem.durability, max: rawItem.maxDurability } + : undefined + + const sockets = tt.sockets ?? (rawItem as any).sockets ?? rawItem.totalSockets ?? rawItem.socketedCount + + const id = rawItem.uniqueId + ? `item-${code}-${rawItem.uniqueId}` + : (rawItem as any).id ?? `item-${code}-${Math.random().toString(36).slice(2, 9)}` + + return { + id, + code, + invFile, + name: rawItem.name || baseEn, + nameZh, + baseNameZh, + quality, + invWidth: rawItem.invWidth ?? base.invWidth ?? 1, + invHeight: rawItem.invHeight ?? base.invHeight ?? 1, + allowedSlots, + ...(tt.defense !== undefined ? { defense: tt.defense } : (rawItem.defense !== undefined || base.defense > 0 ? { defense: rawItem.defense ?? base.defense } : {})), + ...(damage !== undefined ? { damage } : {}), + ...(tt.reqLevel !== undefined ? { reqLevel: tt.reqLevel } : (rawItem.level || base.level ? { reqLevel: rawItem.level || base.level } : {})), + ...(tt.reqStr !== undefined ? { reqStr: tt.reqStr } : ((base as any).reqstr ? { reqStr: (base as any).reqstr } : {})), + stats, + ...(invtransform ? { invtransform } : {}), + ...(chrtransform ? { chrtransform } : {}), + ...(durability ? { durability } : {}), + ...(sockets !== undefined && sockets > 0 ? { sockets } : {}), + ...((rawItem as any).speedText || tt.speedText ? { speedText: (rawItem as any).speedText || tt.speedText } : {}), + ...((rawItem as any).runewordRunes || tt.runewordRunes ? { runewordRunes: (rawItem as any).runewordRunes || tt.runewordRunes } : {}), + ...(tt.setPieces ? { setPieces: tt.setPieces } : {}), + ...(tt.setBonuses ? { setBonuses: tt.setBonuses.map(b => ({ text: b.text, color: (b.color ?? 'green') as D2ColorCode })) } : {}), + } +} diff --git a/tests/item-to-ui-bridge.test.ts b/tests/item-to-ui-bridge.test.ts new file mode 100644 index 0000000..c10fafa --- /dev/null +++ b/tests/item-to-ui-bridge.test.ts @@ -0,0 +1,665 @@ +/** + * 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 & { twoHanded?: boolean; oneOrTwoHanded?: boolean } + + // Helper to create synthetic engine items + function createTestItem(overrides: Omit, '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 PLAYER_GOLD_CAP (2,500,000) on normal pickup', () => { + const engine = createTestEngine() + const invPanel = new InventoryPanel() + invPanel.gold = 2_450_000 + const hudManager: any = { + inventory: invPanel, + syncPublishedState: vi.fn(), + } + + // Drop 100,000 gold pile + const goldDrop = engine.dropGold(100_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!) + + // Gold clamped to 2,500,000 instead of 2,550,000 + expect(invPanel.gold).toBe(PLAYER_GOLD_CAP) + expect(hudManager.syncPublishedState).toHaveBeenCalled() + expect(mockStatus.textContent).toContain('拾起金币:100000') + }) + + it('accumulates gold and clamps to PLAYER_GOLD_CAP on Telekinesis pickup', () => { + const engine = createTestEngine() + const invPanel = new InventoryPanel() + invPanel.gold = 2_490_000 + const hudManager: any = { + inventory: invPanel, + syncPublishedState: vi.fn(), + getSkillLevel: () => 1, + hotkeys: { availableSkills: [{ skillId: 43, manaCost: 0 }] }, + } + + const goldDrop = engine.dropGold(50_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(PLAYER_GOLD_CAP) + 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('包裹已满。') + }) + }) +})