feat(ui): drop item to ui inventory item bridge, bilingual tooltip, and pickup loop (Fixes #407)
This commit is contained in:
parent
5079f364ec
commit
b5754c0197
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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 })) } : {}),
|
||||
}
|
||||
}
|
||||
|
|
@ -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<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 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('包裹已满。')
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue