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
|
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.
|
* 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),
|
* If skillId is an aura skill (e.g. Conviction 123, Holy Freeze 114, Defiance 104),
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,12 @@ import {
|
||||||
resolveLadderCollisions,
|
resolveLadderCollisions,
|
||||||
} from '../ui/ground-labels.ts'
|
} from '../ui/ground-labels.ts'
|
||||||
import { BAKED_UI_MANIFEST } from '../ui/baked-ui-meta.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 type { AtlasFrame } from '../render/atlas.ts'
|
||||||
import { SpriteRenderer } from '../render/renderer.ts'
|
import { SpriteRenderer } from '../render/renderer.ts'
|
||||||
import type { AtlasHandle } from '../render/renderer.ts'
|
import type { AtlasHandle } from '../render/renderer.ts'
|
||||||
|
|
@ -1569,13 +1574,14 @@ export function castSkill(
|
||||||
if (nearbyGroundItem.isGold) {
|
if (nearbyGroundItem.isGold) {
|
||||||
const res = engine.pickupGold(nearbyGroundItem.id)
|
const res = engine.pickupGold(nearbyGroundItem.id)
|
||||||
if (res.success && hudManager) {
|
if (res.success && hudManager) {
|
||||||
hudManager.inventory.gold += res.amount
|
hudManager.inventory.gold = Math.min(PLAYER_GOLD_CAP, hudManager.inventory.gold + res.amount)
|
||||||
hudManager.syncPublishedState()
|
hudManager.syncPublishedState()
|
||||||
}
|
}
|
||||||
status.textContent = `心灵传动拾取金币`
|
status.textContent = `心灵传动拾取金币`
|
||||||
} else {
|
} else {
|
||||||
if (hudManager) {
|
if (hudManager) {
|
||||||
const placed = hudManager.inventory.autoPlaceInGrid(nearbyGroundItem.item)
|
const uiItem = itemToUiInventoryItem(nearbyGroundItem.item, engine.dropTables)
|
||||||
|
const placed = hudManager.inventory.autoPlaceInGrid(uiItem)
|
||||||
if (!placed) {
|
if (!placed) {
|
||||||
engine.metrics.inventoryRefusals += 1
|
engine.metrics.inventoryRefusals += 1
|
||||||
triggerFlippyBounce(nearbyGroundItem, typeof performance !== 'undefined' ? performance.now() : Date.now())
|
triggerFlippyBounce(nearbyGroundItem, typeof performance !== 'undefined' ? performance.now() : Date.now())
|
||||||
|
|
@ -2851,14 +2857,15 @@ export class SceneMouseController {
|
||||||
const res = this.engine.pickupGold(item.id)
|
const res = this.engine.pickupGold(item.id)
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
if (this.hudManager) {
|
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.hudManager.syncPublishedState()
|
||||||
}
|
}
|
||||||
this.status.textContent = `拾起金币:${res.amount}`
|
this.status.textContent = `拾起金币:${res.amount}`
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (this.hudManager) {
|
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) {
|
if (!placed) {
|
||||||
this.engine.metrics.inventoryRefusals += 1
|
this.engine.metrics.inventoryRefusals += 1
|
||||||
triggerFlippyBounce(item, typeof performance !== 'undefined' ? performance.now() : Date.now())
|
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'
|
import { BAKED_UI_MANIFEST, type SpriteRect } from './baked-ui-meta.ts'
|
||||||
|
|
||||||
export type { SpriteRect }
|
export type { SpriteRect }
|
||||||
|
export { itemToUiInventoryItem, resolveAllowedSlots, resolveItemInvFile } from './item-bridge.ts'
|
||||||
|
export { PLAYER_GOLD_CAP } from './world-panels.ts'
|
||||||
|
|
||||||
export type EquipSlotId =
|
export type EquipSlotId =
|
||||||
| 'helm'
|
| '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