feat(items): spawn magic/rare/set/unique drops unidentified and implement full 1.13c identification
This commit is contained in:
parent
7cc56ed967
commit
e9c3b41768
|
|
@ -566,6 +566,21 @@ export function rollItemEtherealAndSockets(
|
|||
return { ethereal, sockets }
|
||||
}
|
||||
|
||||
/**
|
||||
* Per 1.13c D2Game.dll (ITEMS_Create / 0x6FC2FC40):
|
||||
* - Low Quality (1), Normal (2), Superior (3), and Quest items (quest > 0) spawn identified.
|
||||
* - Magic (4), Set (5), Rare (6), Unique (7), and Crafted (8) non-quest items spawn unidentified on drop.
|
||||
*/
|
||||
export function shouldSpawnIdentifiedByDefault(
|
||||
base: ItemBase,
|
||||
qualityTier: ItemQualityTier,
|
||||
): boolean {
|
||||
if ((base.quest ?? 0) > 0) {
|
||||
return true
|
||||
}
|
||||
return qualityTier === 'low' || qualityTier === 'normal' || qualityTier === 'superior'
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a concrete Item instance for a dropped leaf item.
|
||||
*/
|
||||
|
|
@ -587,10 +602,12 @@ export function createDroppedItem(
|
|||
readonly dwInitSeed: number
|
||||
readonly durabilityMultiplier?: number | undefined
|
||||
readonly itemRng?: D2Rng | undefined
|
||||
readonly identified?: boolean | undefined
|
||||
},
|
||||
): Item {
|
||||
const quality = QUALITY_TIER_TO_ITEM_QUALITY[qualityTier] ?? ItemQuality.NORMAL
|
||||
let rawFlags = ItemFlag.IDENTIFIED
|
||||
const isIdentified = options.identified ?? shouldSpawnIdentifiedByDefault(base, qualityTier)
|
||||
let rawFlags = isIdentified ? ItemFlag.IDENTIFIED : 0
|
||||
|
||||
let name = base.name
|
||||
let prefix = null
|
||||
|
|
@ -894,6 +911,7 @@ export function createDroppedItem(
|
|||
value: base.value,
|
||||
rawFlags,
|
||||
flags: decodeItemFlags(rawFlags),
|
||||
identified: isIdentified,
|
||||
version: 0x65,
|
||||
code: formatItemCode(base.id),
|
||||
quality,
|
||||
|
|
|
|||
|
|
@ -29,7 +29,12 @@ import {
|
|||
rayTraceScene,
|
||||
} from './d2map.ts'
|
||||
import type { CollisionGrid } from './d2map.ts'
|
||||
import { translateItemName, translateMagicName, translateRareName } from './tooltip-i18n.ts'
|
||||
import {
|
||||
resolveBaseItemNameEn,
|
||||
translateItemName,
|
||||
translateMagicName,
|
||||
translateRareName,
|
||||
} from './tooltip-i18n.ts'
|
||||
import { isRuneCode } from './gems-data.ts'
|
||||
import { getEmbeddedDropTables } from './embedded-drop-tables.ts'
|
||||
|
||||
|
|
@ -181,6 +186,25 @@ export interface GroundItemMetadata {
|
|||
readonly sockets?: number | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether an item or GroundItemSource is currently unidentified.
|
||||
*/
|
||||
export function isGroundSourceUnidentified(item: any): boolean {
|
||||
if (!item || isGoldItem(item)) return false
|
||||
if (item.identified === false) return true
|
||||
if (item.identified === true) return false
|
||||
if (item.flags && typeof item.flags.identified === 'boolean') {
|
||||
return item.flags.identified === false
|
||||
}
|
||||
if (item.rawItem && item.rawItem !== item) {
|
||||
return isGroundSourceUnidentified(item.rawItem)
|
||||
}
|
||||
if (item.item && item.item !== item) {
|
||||
return isGroundSourceUnidentified(item.item)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** Extract ground item metadata from any item source. */
|
||||
export function resolveGroundItemMetadata(item: any, specifiedAmount?: number): GroundItemMetadata {
|
||||
const isGold = isGoldItem(item)
|
||||
|
|
@ -208,21 +232,15 @@ export function resolveGroundItemMetadata(item: any, specifiedAmount?: number):
|
|||
}
|
||||
}
|
||||
|
||||
const name = typeof item?.name === 'string' ? item.name : 'Unknown Item'
|
||||
const rawCode = typeof item?.code === 'string'
|
||||
? item.code.trim().toLowerCase()
|
||||
: typeof item?.base?.id === 'string'
|
||||
? item.base.id.trim().toLowerCase()
|
||||
: ''
|
||||
const quality = resolveItemQualityString(item?.quality ?? item?.base?.quality, rawCode, item)
|
||||
const nameZh = formatBilingualGroundItemName(item, name, quality)
|
||||
if (item && typeof item === 'object' && (!item.nameZh || item.nameZh === name) && nameZh) {
|
||||
try {
|
||||
item.nameZh = nameZh
|
||||
} catch {
|
||||
// ignore if frozen object
|
||||
}
|
||||
}
|
||||
: typeof item?.rawItem?.code === 'string'
|
||||
? item.rawItem.code.trim().toLowerCase()
|
||||
: typeof item?.rawItem?.base?.id === 'string'
|
||||
? item.rawItem.base.id.trim().toLowerCase()
|
||||
: ''
|
||||
|
||||
let tableBase: any
|
||||
if (rawCode && (!item?.base || (item?.base?.dropsound === undefined && item?.dropSound === undefined && item?.dropsound === undefined))) {
|
||||
|
|
@ -236,6 +254,39 @@ export function resolveGroundItemMetadata(item: any, specifiedAmount?: number):
|
|||
}
|
||||
}
|
||||
|
||||
const rawName = typeof item?.name === 'string' ? item.name : 'Unknown Item'
|
||||
const quality = resolveItemQualityString(item?.quality ?? item?.base?.quality, rawCode, item)
|
||||
const unidentified = isGroundSourceUnidentified(item)
|
||||
|
||||
let name = rawName
|
||||
let nameZh: string
|
||||
if (unidentified) {
|
||||
const baseObj = item?.base ?? item?.rawItem?.base ?? tableBase
|
||||
const rawBaseName =
|
||||
typeof baseObj?.name === 'string' && baseObj.name.trim().length > 0
|
||||
? baseObj.name
|
||||
: rawName
|
||||
const baseNameEn = resolveBaseItemNameEn(rawBaseName, rawCode)
|
||||
const translatedBase =
|
||||
translateItemName(rawBaseName) !== rawBaseName
|
||||
? translateItemName(rawBaseName)
|
||||
: translateItemName(baseNameEn)
|
||||
name = baseNameEn
|
||||
nameZh =
|
||||
translatedBase && translatedBase !== baseNameEn
|
||||
? `${translatedBase} (${baseNameEn})`
|
||||
: baseNameEn
|
||||
} else {
|
||||
nameZh = formatBilingualGroundItemName(item, name, quality)
|
||||
if (item && typeof item === 'object' && (!item.nameZh || item.nameZh === name) && nameZh) {
|
||||
try {
|
||||
item.nameZh = nameZh
|
||||
} catch {
|
||||
// ignore if frozen object
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const invWidth = typeof item?.invWidth === 'number' ? item.invWidth : (item?.base?.invWidth ?? tableBase?.invWidth ?? 1)
|
||||
const invHeight = typeof item?.invHeight === 'number' ? item.invHeight : (item?.base?.invHeight ?? tableBase?.invHeight ?? 1)
|
||||
const amount = typeof item?.stack === 'number' ? item.stack : 1
|
||||
|
|
|
|||
|
|
@ -510,7 +510,8 @@ export function calculateTransactionCost(options: TransactionCostOptions): numbe
|
|||
nRepCost = afterSkill.repCost
|
||||
}
|
||||
|
||||
const isIdentified = item.flags?.identified !== false && !item.isGambleMystery
|
||||
const isIdentified =
|
||||
item.identified !== false && item.flags?.identified !== false && !item.isGambleMystery
|
||||
|
||||
if (isIdentified) {
|
||||
let nSell = 0
|
||||
|
|
|
|||
|
|
@ -350,6 +350,7 @@ export function generateVendorStoreItem(options: {
|
|||
ilvl: nItemLevel,
|
||||
dwInitSeed,
|
||||
itemRng,
|
||||
identified: true,
|
||||
})
|
||||
|
||||
// Roll authentic armor defense in [minac, maxac] (or maxac + 1 if enhanced defense)
|
||||
|
|
@ -779,6 +780,7 @@ export function resolveGambledItem(options: {
|
|||
ilvl,
|
||||
dwInitSeed,
|
||||
itemRng,
|
||||
identified: true,
|
||||
})
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -12,8 +12,13 @@
|
|||
* - Clickable labels to trigger pathfinding and item pickup.
|
||||
*/
|
||||
|
||||
import type { GroundItemEntity } from '../game/ground-items.ts'
|
||||
import { translateItemName, translateRareName, translateMagicName } from '../game/tooltip-i18n.ts'
|
||||
import { isGroundSourceUnidentified, type GroundItemEntity } from '../game/ground-items.ts'
|
||||
import {
|
||||
resolveBaseItemNameEn,
|
||||
translateItemName,
|
||||
translateRareName,
|
||||
translateMagicName,
|
||||
} from '../game/tooltip-i18n.ts'
|
||||
|
||||
/**
|
||||
* Authentic 1.13c Diablo II quality color codes for ground item labels.
|
||||
|
|
@ -112,6 +117,31 @@ export function formatGroundItemLabelText(item: GroundItemEntity): string {
|
|||
if (item.isGold) {
|
||||
return `${item.amount} 金币`
|
||||
}
|
||||
const src = item.item as any
|
||||
if (isGroundSourceUnidentified(src)) {
|
||||
const baseObj = src?.base ?? src?.rawItem?.base ?? src?.item?.base
|
||||
const rawCode =
|
||||
typeof src?.code === 'string'
|
||||
? src.code.trim().toLowerCase()
|
||||
: typeof baseObj?.id === 'string'
|
||||
? baseObj.id.trim().toLowerCase()
|
||||
: ''
|
||||
const rawBaseName =
|
||||
typeof baseObj?.name === 'string' && baseObj.name.trim().length > 0
|
||||
? baseObj.name
|
||||
: item.name
|
||||
if (rawBaseName) {
|
||||
const baseNameEn = resolveBaseItemNameEn(rawBaseName, rawCode)
|
||||
if (item.nameZh && item.name === baseNameEn) {
|
||||
return item.nameZh
|
||||
}
|
||||
const translatedBase =
|
||||
translateItemName(rawBaseName) !== rawBaseName
|
||||
? translateItemName(rawBaseName)
|
||||
: translateItemName(baseNameEn)
|
||||
return translatedBase || baseNameEn
|
||||
}
|
||||
}
|
||||
if (item.nameZh) {
|
||||
return item.nameZh
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ export interface UiInventoryItem {
|
|||
readonly id: string
|
||||
readonly code: string
|
||||
readonly invFile: string
|
||||
readonly baseInvFile?: string | undefined
|
||||
readonly name: string
|
||||
readonly nameZh: string
|
||||
readonly baseNameZh: string
|
||||
|
|
@ -146,6 +147,7 @@ export interface ResolvableItem {
|
|||
readonly nameZh?: string | undefined
|
||||
readonly baseNameZh?: string | undefined
|
||||
readonly invFile?: string | undefined
|
||||
readonly baseInvFile?: string | undefined
|
||||
readonly flippyFile?: string | undefined
|
||||
readonly isGold?: boolean | undefined
|
||||
readonly amount?: number | undefined
|
||||
|
|
@ -158,6 +160,9 @@ export interface ResolvableItem {
|
|||
readonly invHeight?: number | undefined
|
||||
readonly width?: number | undefined
|
||||
readonly height?: number | undefined
|
||||
readonly identified?: boolean | undefined
|
||||
readonly isGambleMystery?: boolean | undefined
|
||||
readonly rawItem?: Item | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -179,7 +184,28 @@ export function resolveItemSpriteRect(
|
|||
itemRects: Record<string, SpriteRect> = BAKED_UI_MANIFEST.itemRects,
|
||||
): SpriteRect | null {
|
||||
const normalizedCode = normalizeItemCode(rawItem)
|
||||
const item: ResolvableItem = normalizedCode === rawItem.code ? rawItem : { ...rawItem, code: normalizedCode }
|
||||
const normalizedItem: ResolvableItem =
|
||||
normalizedCode === rawItem.code ? rawItem : { ...rawItem, code: normalizedCode }
|
||||
const isUnid = Boolean(
|
||||
normalizedItem.identified === false ||
|
||||
normalizedItem.isGambleMystery === true ||
|
||||
normalizedItem.rawItem?.identified === false ||
|
||||
normalizedItem.rawItem?.isGambleMystery === true ||
|
||||
(normalizedItem.rawItem?.flags !== undefined && normalizedItem.rawItem.flags.identified === false),
|
||||
)
|
||||
// Per D2Client.dll, unidentified items render their base item graphic without unique/set invfile or dye transform
|
||||
const item: ResolvableItem = isUnid
|
||||
? {
|
||||
...normalizedItem,
|
||||
invFile:
|
||||
normalizedItem.baseInvFile ??
|
||||
(normalizedItem.code && BAKED_UI_MANIFEST.codeToInvFile[normalizedItem.code]
|
||||
? BAKED_UI_MANIFEST.codeToInvFile[normalizedItem.code]
|
||||
: normalizedItem.invFile),
|
||||
invtransform: undefined,
|
||||
name: normalizedItem.rawItem?.base?.name ?? undefined,
|
||||
}
|
||||
: normalizedItem
|
||||
// Tier 0: Transformed dyed item lookup: `${invFile}_${invtransform}`
|
||||
const targetInv =
|
||||
item.invFile ||
|
||||
|
|
@ -1056,6 +1082,8 @@ export function identifyUiItem(item: UiInventoryItem): UiInventoryItem {
|
|||
}
|
||||
const rebuilt = itemToUiInventoryItem(item.rawItem)
|
||||
Object.assign(item, {
|
||||
invFile: rebuilt.invFile,
|
||||
...(rebuilt.baseInvFile !== undefined ? { baseInvFile: rebuilt.baseInvFile } : {}),
|
||||
name: rebuilt.name,
|
||||
nameZh: rebuilt.nameZh,
|
||||
baseNameZh: rebuilt.baseNameZh,
|
||||
|
|
@ -1645,8 +1673,9 @@ export class InventoryPanel {
|
|||
const gw = placed.item.invWidth * INV_GRID_ORIGIN.cellPx
|
||||
const gh = placed.item.invHeight * INV_GRID_ORIGIN.cellPx
|
||||
|
||||
ctx.fillStyle =
|
||||
placed.item.quality === 'unique'
|
||||
ctx.fillStyle = isUiItemUnidentified(placed.item)
|
||||
? 'rgba(72, 18, 18, 0.50)'
|
||||
: placed.item.quality === 'unique'
|
||||
? 'rgba(58, 42, 16, 0.45)'
|
||||
: placed.item.quality === 'set'
|
||||
? 'rgba(16, 52, 22, 0.45)'
|
||||
|
|
|
|||
|
|
@ -383,6 +383,12 @@ export function itemToUiInventoryItem(
|
|||
|
||||
// 3. invFile & Dye Resolution
|
||||
const { invFile, invtransform, chrtransform } = resolveItemInvFile(rawItem, quality, code, dropTables)
|
||||
const { invFile: baseInvFile } = resolveItemInvFile(
|
||||
{ ...rawItem, name: baseEn, uniqueItemDef: undefined, setItemDef: undefined } as Item,
|
||||
'normal',
|
||||
code,
|
||||
dropTables,
|
||||
)
|
||||
|
||||
// 4. Allowed Equipment Slots
|
||||
const allowedSlots = resolveAllowedSlots(
|
||||
|
|
@ -494,6 +500,7 @@ export function itemToUiInventoryItem(
|
|||
id,
|
||||
code,
|
||||
invFile,
|
||||
baseInvFile,
|
||||
name: rawItem.name || baseEn,
|
||||
nameZh,
|
||||
baseNameZh,
|
||||
|
|
|
|||
|
|
@ -2420,12 +2420,19 @@ export class WorldPanelsHud {
|
|||
: 'white'
|
||||
|
||||
// Header: Title, Runeword Runes line, Base item name
|
||||
lines.push({ text: item.nameZh, color: titleColor, font: 'fontexocet10' })
|
||||
if (item.runewordRunes) {
|
||||
lines.push({ text: item.runewordRunes, color: 'gold', font: 'font8' })
|
||||
}
|
||||
if (item.baseNameZh) {
|
||||
lines.push({ text: item.baseNameZh, color: 'white', font: 'font8' })
|
||||
const unid = isUiItemUnidentified(item)
|
||||
if (unid) {
|
||||
const maskedBaseHeader =
|
||||
(item.baseNameZh ? item.baseNameZh.split(' · ')[0] : undefined) || item.nameZh
|
||||
lines.push({ text: maskedBaseHeader, color: titleColor, font: 'fontexocet10' })
|
||||
} else {
|
||||
lines.push({ text: item.nameZh, color: titleColor, font: 'fontexocet10' })
|
||||
if (item.runewordRunes) {
|
||||
lines.push({ text: item.runewordRunes, color: 'gold', font: 'font8' })
|
||||
}
|
||||
if (item.baseNameZh) {
|
||||
lines.push({ text: item.baseNameZh, color: 'white', font: 'font8' })
|
||||
}
|
||||
}
|
||||
|
||||
// Vendor Transaction Cost Line (`ITEMS_CalculateTransactionCost` at 0x6FD9B1C0)
|
||||
|
|
@ -2467,33 +2474,52 @@ export class WorldPanelsHud {
|
|||
if (item.reqStr !== undefined) {
|
||||
lines.push({ text: '需要力量: ' + item.reqStr, color: 'white', font: 'font8' })
|
||||
}
|
||||
if (item.reqLevel !== undefined) {
|
||||
lines.push({ text: '需要等级: ' + item.reqLevel, color: 'white', font: 'font8' })
|
||||
const displayReqLevel = unid
|
||||
? item.rawItem?.base?.level && item.rawItem.base.level > 1
|
||||
? item.rawItem.base.level
|
||||
: undefined
|
||||
: item.reqLevel
|
||||
if (displayReqLevel !== undefined) {
|
||||
lines.push({ text: '需要等级: ' + displayReqLevel, color: 'white', font: 'font8' })
|
||||
}
|
||||
|
||||
// Magic Affixes
|
||||
for (const st of item.stats) {
|
||||
lines.push({ text: st.text, color: st.color ?? 'blue', font: 'font8' })
|
||||
}
|
||||
|
||||
// Sockets Block
|
||||
if (
|
||||
item.sockets !== undefined &&
|
||||
item.sockets > 0 &&
|
||||
!item.stats.some(s => s.text.includes('凹槽') || s.text.includes('Socketed'))
|
||||
) {
|
||||
lines.push({ text: '凹槽 (' + item.sockets + ')', color: 'blue', font: 'font8' })
|
||||
}
|
||||
|
||||
// Set Pieces & Set Bonuses Block
|
||||
if (item.setPieces) {
|
||||
for (const piece of item.setPieces) {
|
||||
lines.push({ text: piece, color: 'gray', font: 'font8' })
|
||||
if (unid) {
|
||||
if (item.isGambleMystery) {
|
||||
lines.push({ text: '未鉴定 (Unidentified Gamble Item)', color: 'red', font: 'font8' })
|
||||
} else {
|
||||
lines.push({ text: '未辨识 (Unidentified)', color: 'red', font: 'font8' })
|
||||
if (item.allowedSlots.length > 0) {
|
||||
lines.push({ text: '无法使用 (Cannot be Equipped)', color: 'red', font: 'font8' })
|
||||
}
|
||||
if (item.ethereal) {
|
||||
lines.push({ text: '无形的 (无法修复) (Ethereal)', color: 'blue', font: 'font8' })
|
||||
}
|
||||
}
|
||||
}
|
||||
if (item.setBonuses) {
|
||||
for (const bonus of item.setBonuses) {
|
||||
lines.push({ text: bonus.text, color: bonus.color ?? 'green', font: 'font8' })
|
||||
} else {
|
||||
// Magic Affixes
|
||||
for (const st of item.stats) {
|
||||
lines.push({ text: st.text, color: st.color ?? 'blue', font: 'font8' })
|
||||
}
|
||||
|
||||
// Sockets Block
|
||||
if (
|
||||
item.sockets !== undefined &&
|
||||
item.sockets > 0 &&
|
||||
!item.stats.some(s => s.text.includes('凹槽') || s.text.includes('Socketed'))
|
||||
) {
|
||||
lines.push({ text: '凹槽 (' + item.sockets + ')', color: 'blue', font: 'font8' })
|
||||
}
|
||||
|
||||
// Set Pieces & Set Bonuses Block
|
||||
if (item.setPieces) {
|
||||
for (const piece of item.setPieces) {
|
||||
lines.push({ text: piece, color: 'gray', font: 'font8' })
|
||||
}
|
||||
}
|
||||
if (item.setBonuses) {
|
||||
for (const bonus of item.setBonuses) {
|
||||
lines.push({ text: bonus.text, color: bonus.color ?? 'green', font: 'font8' })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -35,6 +35,8 @@ import { itemToUiInventoryItem } from '../src/ui/item-bridge.ts'
|
|||
import {
|
||||
InventoryPanel,
|
||||
resolveItemSpriteRect,
|
||||
identifyUiItem,
|
||||
isUiItemUnidentified,
|
||||
PLAYER_GOLD_CAP,
|
||||
type UiInventoryItem,
|
||||
type EquipSlotId,
|
||||
|
|
@ -575,6 +577,14 @@ describe('Milestone M11.5 (Issue #408) — Monster Drop End-to-End Integration S
|
|||
expect(invPanel.equipped[invalidSlot]).toBeUndefined()
|
||||
expect(invPanel.cursorItem).toBe(placed!.item)
|
||||
|
||||
// Unidentified magic/rare/set/unique drops cannot be equipped until identified (D2 1.13c)
|
||||
if (isUiItemUnidentified(placed!.item)) {
|
||||
const unidEquip = invPanel.clickEquipSlot(equippableSlot!)
|
||||
expect(unidEquip).toBe(false)
|
||||
expect(invPanel.equipped[equippableSlot!]).toBeUndefined()
|
||||
identifyUiItem(placed!.item)
|
||||
}
|
||||
|
||||
// Equip into valid matching slot
|
||||
const validEquip = invPanel.clickEquipSlot(equippableSlot!)
|
||||
expect(validEquip).toBe(true)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,449 @@
|
|||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { D2Rng } from '../src/game/d2-rng.ts'
|
||||
import {
|
||||
createDroppedItem,
|
||||
shouldSpawnIdentifiedByDefault,
|
||||
} from '../src/game/drop-pipeline.ts'
|
||||
import { getEmbeddedDropTables } from '../src/game/embedded-drop-tables.ts'
|
||||
import {
|
||||
GroundItemManager,
|
||||
isGroundSourceUnidentified,
|
||||
resolveGroundItemMetadata,
|
||||
} from '../src/game/ground-items.ts'
|
||||
import {
|
||||
calculateTransactionCost,
|
||||
TransactionType,
|
||||
} from '../src/game/item-cost.ts'
|
||||
import { ItemFlag, ItemQuality, type Item } from '../src/game/items.ts'
|
||||
import { VendorNpcId } from '../src/game/npc-table.ts'
|
||||
import {
|
||||
generateGambleInventory,
|
||||
generateVendorStoreItem,
|
||||
resolveGambledItem,
|
||||
} from '../src/game/vendor-generator.ts'
|
||||
import { BAKED_UI_MANIFEST } from '../src/ui/baked-ui-meta.ts'
|
||||
import type { D2ColorCode, D2FontName, D2FontRenderer } from '../src/ui/font.ts'
|
||||
import {
|
||||
formatGroundItemLabelText,
|
||||
getGroundItemQualityColor,
|
||||
GROUND_LABEL_QUALITY_COLORS,
|
||||
} from '../src/ui/ground-labels.ts'
|
||||
import {
|
||||
EQUIP_SLOTS_LAYOUT,
|
||||
identifyUiItem,
|
||||
INV_GRID_ORIGIN,
|
||||
InventoryPanel,
|
||||
isUiItemUnidentified,
|
||||
itemToUiInventoryItem,
|
||||
resolveItemSpriteRect,
|
||||
type UiInventoryItem,
|
||||
} from '../src/ui/inventory.ts'
|
||||
import { WorldPanelsHud } from '../src/ui/world-panels.ts'
|
||||
|
||||
function createMockTooltipCanvas() {
|
||||
const fontCalls: Array<{ text: string; color?: D2ColorCode | undefined; font?: D2FontName | undefined }> = []
|
||||
const fillStyles: string[] = []
|
||||
const ctx = {
|
||||
save: vi.fn(),
|
||||
restore: vi.fn(),
|
||||
drawImage: vi.fn(),
|
||||
fillRect: vi.fn(function (this: { fillStyle: string }) {
|
||||
fillStyles.push(this.fillStyle)
|
||||
}),
|
||||
strokeRect: vi.fn(),
|
||||
beginPath: vi.fn(),
|
||||
moveTo: vi.fn(),
|
||||
lineTo: vi.fn(),
|
||||
stroke: vi.fn(),
|
||||
fillText: vi.fn(),
|
||||
fillStyle: '',
|
||||
strokeStyle: '',
|
||||
lineWidth: 1,
|
||||
} as unknown as CanvasRenderingContext2D
|
||||
|
||||
const font = {
|
||||
loaded: true,
|
||||
measureText: vi.fn((text: string) => text.length * 8),
|
||||
drawText: vi.fn(
|
||||
(
|
||||
_ctx: CanvasRenderingContext2D,
|
||||
text: string,
|
||||
_x: number,
|
||||
_y: number,
|
||||
opts?: { font?: D2FontName; color?: D2ColorCode },
|
||||
) => {
|
||||
fontCalls.push({ text, color: opts?.color, font: opts?.font })
|
||||
},
|
||||
),
|
||||
} as unknown as D2FontRenderer
|
||||
|
||||
return { ctx, font, fontCalls, fillStyles }
|
||||
}
|
||||
|
||||
describe('Diablo II v1.13c Unidentified Equipment Drops & Identification System', () => {
|
||||
const dropTables = getEmbeddedDropTables()
|
||||
const shakoBase = dropTables.getBase('uap')!
|
||||
const harlequinDef = dropTables.uniques.getEnabledByCode('uap')[0]!
|
||||
|
||||
function makeDroppedShako(qualityTier: 'normal' | 'magic' | 'rare' | 'unique', seed = 101): Item {
|
||||
const rng = new D2Rng(seed)
|
||||
return createDroppedItem(shakoBase, qualityTier, {
|
||||
uniqueItem: qualityTier === 'unique' ? harlequinDef : undefined,
|
||||
itemTypes: dropTables.itemTypes,
|
||||
ilvl: 85,
|
||||
dwInitSeed: seed,
|
||||
itemRng: rng,
|
||||
})
|
||||
}
|
||||
|
||||
describe('1. Drop Pipeline Unidentified Spawn Rules (D2Game.dll / Items.cpp)', () => {
|
||||
it('spawns low, normal, superior, and quest items as identified, and magic/rare/set/unique/crafted items as unidentified', () => {
|
||||
expect(shakoBase).toBeDefined()
|
||||
expect(harlequinDef).toBeDefined()
|
||||
|
||||
expect(shouldSpawnIdentifiedByDefault(shakoBase, 'low')).toBe(true)
|
||||
expect(shouldSpawnIdentifiedByDefault(shakoBase, 'normal')).toBe(true)
|
||||
expect(shouldSpawnIdentifiedByDefault(shakoBase, 'superior')).toBe(true)
|
||||
expect(shouldSpawnIdentifiedByDefault(shakoBase, 'magic')).toBe(false)
|
||||
expect(shouldSpawnIdentifiedByDefault(shakoBase, 'rare')).toBe(false)
|
||||
expect(shouldSpawnIdentifiedByDefault(shakoBase, 'set')).toBe(false)
|
||||
expect(shouldSpawnIdentifiedByDefault(shakoBase, 'unique')).toBe(false)
|
||||
expect(shouldSpawnIdentifiedByDefault(shakoBase, 'crafted' as any)).toBe(false)
|
||||
|
||||
// Quest items (base.quest > 0) always spawn identified regardless of quality tier
|
||||
const cubeBase = dropTables.getBase('box')!
|
||||
expect(cubeBase.quest).toBeGreaterThan(0)
|
||||
expect(shouldSpawnIdentifiedByDefault(cubeBase, 'unique')).toBe(true)
|
||||
|
||||
// Verify concrete createDroppedItem outputs
|
||||
const normalItem = makeDroppedShako('normal', 1)
|
||||
expect(normalItem.identified).toBe(true)
|
||||
expect(normalItem.flags?.identified).toBe(true)
|
||||
expect((normalItem.rawFlags! & ItemFlag.IDENTIFIED) !== 0).toBe(true)
|
||||
|
||||
const magicItem = makeDroppedShako('magic', 2)
|
||||
expect(magicItem.identified).toBe(false)
|
||||
expect(magicItem.flags?.identified).toBe(false)
|
||||
expect((magicItem.rawFlags! & ItemFlag.IDENTIFIED) !== 0).toBe(false)
|
||||
|
||||
const rareItem = makeDroppedShako('rare', 3)
|
||||
expect(rareItem.identified).toBe(false)
|
||||
expect(rareItem.flags?.identified).toBe(false)
|
||||
expect((rareItem.rawFlags! & ItemFlag.IDENTIFIED) !== 0).toBe(false)
|
||||
|
||||
const uniqueShako = makeDroppedShako('unique', 4)
|
||||
expect(uniqueShako.quality).toBe(ItemQuality.UNIQUE)
|
||||
expect(uniqueShako.name).toBe('Harlequin Crest')
|
||||
expect(uniqueShako.identified).toBe(false)
|
||||
expect(uniqueShako.flags?.identified).toBe(false)
|
||||
expect((uniqueShako.rawFlags! & ItemFlag.IDENTIFIED) !== 0).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('2. Vendor Store, Gambling Reveal & Unidentified Transaction Cost', () => {
|
||||
it('spawns vendor trade items and resolved gamble items as pre-identified', () => {
|
||||
const rng = new D2Rng(42)
|
||||
const capBase = dropTables.getBase('cap')!
|
||||
const vendorMagic = generateVendorStoreItem({
|
||||
base: capBase,
|
||||
vendorId: VendorNpcId.CHARSI,
|
||||
qualityTier: 'magic',
|
||||
cLvl: 25,
|
||||
nItemLevel: 30,
|
||||
difficulty: 0,
|
||||
permStore: false,
|
||||
tables: dropTables,
|
||||
rng,
|
||||
})
|
||||
expect(vendorMagic).not.toBeNull()
|
||||
expect(vendorMagic!.identified).toBe(true)
|
||||
expect(vendorMagic!.flags?.identified).toBe(true)
|
||||
expect((vendorMagic!.rawFlags! & ItemFlag.IDENTIFIED) !== 0).toBe(true)
|
||||
|
||||
const gambleGrid = generateGambleInventory({
|
||||
vendorId: VendorNpcId.GHEED,
|
||||
cLvl: 30,
|
||||
tables: dropTables,
|
||||
rng,
|
||||
})
|
||||
const mystery = gambleGrid.list()[0]!.item
|
||||
expect(mystery.isGambleMystery).toBe(true)
|
||||
expect(mystery.identified).toBe(false)
|
||||
|
||||
const gambled = resolveGambledItem({
|
||||
mysteryItem: mystery,
|
||||
cLvl: 30,
|
||||
tables: dropTables,
|
||||
rng,
|
||||
})
|
||||
expect(gambled.identified).toBe(true)
|
||||
expect(gambled.flags?.identified).toBe(true)
|
||||
expect(gambled.isGambleMystery).toBe(false)
|
||||
})
|
||||
|
||||
it('discounts sell price of unidentified magic/unique items until identified (0x6FD9B1C0)', () => {
|
||||
const uniqueShako = makeDroppedShako('unique', 7)
|
||||
expect(uniqueShako.identified).toBe(false)
|
||||
|
||||
const unidSellCost = calculateTransactionCost({
|
||||
item: uniqueShako,
|
||||
transactionType: TransactionType.SELL,
|
||||
vendorId: VendorNpcId.CHARSI,
|
||||
difficulty: 2,
|
||||
tables: dropTables,
|
||||
})
|
||||
const idShako: Item = {
|
||||
...uniqueShako,
|
||||
identified: true,
|
||||
flags: { ...uniqueShako.flags!, identified: true },
|
||||
rawFlags: uniqueShako.rawFlags! | ItemFlag.IDENTIFIED,
|
||||
}
|
||||
const idSellCost = calculateTransactionCost({
|
||||
item: idShako,
|
||||
transactionType: TransactionType.SELL,
|
||||
vendorId: VendorNpcId.CHARSI,
|
||||
difficulty: 2,
|
||||
tables: dropTables,
|
||||
})
|
||||
|
||||
expect(unidSellCost).toBeGreaterThan(0)
|
||||
expect(idSellCost).toBeGreaterThan(unidSellCost)
|
||||
})
|
||||
})
|
||||
|
||||
describe('3. Ground Item Metadata & Alt Label Masking', () => {
|
||||
it('masks unidentified ground item labels to base item name while preserving quality color and underlying secret name', () => {
|
||||
const uniqueShako = makeDroppedShako('unique', 11)
|
||||
expect(isGroundSourceUnidentified(uniqueShako)).toBe(true)
|
||||
|
||||
const meta = resolveGroundItemMetadata(uniqueShako)
|
||||
expect(meta.quality).toBe('unique')
|
||||
expect(meta.name).toBe('Shako')
|
||||
expect(meta.nameZh).toContain('军帽')
|
||||
expect(meta.nameZh).not.toContain('Harlequin Crest')
|
||||
expect(meta.nameZh).not.toContain('谐角之冠')
|
||||
|
||||
const manager = new GroundItemManager()
|
||||
const groundEntity = manager.add(uniqueShako, 10, 10, 320, 240)
|
||||
expect(getGroundItemQualityColor(groundEntity)).toBe(GROUND_LABEL_QUALITY_COLORS.unique)
|
||||
|
||||
const labelText = formatGroundItemLabelText(groundEntity)
|
||||
expect(labelText).toContain('军帽')
|
||||
expect(labelText).not.toContain('Harlequin Crest')
|
||||
expect(labelText).not.toContain('谐角之冠')
|
||||
|
||||
// Underlying item's secret identity is untouched so picking it up and identifying it works
|
||||
expect(uniqueShako.name).toBe('Harlequin Crest')
|
||||
})
|
||||
})
|
||||
|
||||
describe('4. UI Item Bridge, Sprite/Dye Masking, Tooltip Masking & Equipping Gate', () => {
|
||||
it('masks unique dye transform and custom unique invFile while unidentified and restores them upon identification', () => {
|
||||
const uniqueShako = makeDroppedShako('unique', 19)
|
||||
const uiShako = itemToUiInventoryItem(uniqueShako, dropTables)
|
||||
|
||||
expect(isUiItemUnidentified(uiShako)).toBe(true)
|
||||
expect(uiShako.invtransform).toBeTruthy()
|
||||
expect(uiShako.baseInvFile).toBe('invcap')
|
||||
|
||||
const unidSprite = resolveItemSpriteRect(uiShako)
|
||||
const baseCapSprite = BAKED_UI_MANIFEST.itemRects['invcap']
|
||||
const dyedCapSprite = BAKED_UI_MANIFEST.itemRects[`invcap_${uiShako.invtransform}`]
|
||||
expect(unidSprite).toEqual(baseCapSprite)
|
||||
if (dyedCapSprite) {
|
||||
expect(unidSprite).not.toEqual(dyedCapSprite)
|
||||
}
|
||||
|
||||
// Identify and verify dyed sprite is now resolved
|
||||
identifyUiItem(uiShako)
|
||||
expect(isUiItemUnidentified(uiShako)).toBe(false)
|
||||
if (dyedCapSprite) {
|
||||
expect(resolveItemSpriteRect(uiShako)).toEqual(dyedCapSprite)
|
||||
}
|
||||
})
|
||||
|
||||
it('renders red cell backdrop in InventoryPanel.draw and hides secret name/affixes in drawItemTooltip while unidentified', () => {
|
||||
const uniqueShako = makeDroppedShako('unique', 23)
|
||||
const uiShako = itemToUiInventoryItem(uniqueShako, dropTables)
|
||||
|
||||
const inv = new InventoryPanel()
|
||||
inv.visible = true
|
||||
inv.gridItems = [{ item: uiShako, col: 0, row: 0 }]
|
||||
|
||||
const { ctx, font, fontCalls, fillStyles } = createMockTooltipCanvas()
|
||||
inv.draw(
|
||||
ctx,
|
||||
{
|
||||
borderRightImg: null,
|
||||
invSheetImg: null,
|
||||
invTab0Img: null,
|
||||
invTab1Img: null,
|
||||
buySellBtnImg: null,
|
||||
itemsAtlasImg: null,
|
||||
itemRects: BAKED_UI_MANIFEST.itemRects,
|
||||
},
|
||||
font,
|
||||
)
|
||||
expect(fillStyles).toContain('rgba(72, 18, 18, 0.50)')
|
||||
|
||||
// Hover tooltip while unidentified
|
||||
const wp = new WorldPanelsHud()
|
||||
fontCalls.length = 0
|
||||
wp.drawItemTooltip(ctx, { item: uiShako, x: 450, y: 300 }, font, false)
|
||||
|
||||
const texts = fontCalls.map(c => c.text)
|
||||
expect(texts[0]).toContain('军帽')
|
||||
expect(texts.some(t => t.includes('Harlequin Crest') || t.includes('谐角之冠'))).toBe(false)
|
||||
expect(texts).toContain('未辨识 (Unidentified)')
|
||||
expect(texts).toContain('无法使用 (Cannot be Equipped)')
|
||||
|
||||
// Hover tooltip after identification
|
||||
identifyUiItem(uiShako)
|
||||
fontCalls.length = 0
|
||||
wp.drawItemTooltip(ctx, { item: uiShako, x: 450, y: 300 }, font, false)
|
||||
const identifiedTexts = fontCalls.map(c => c.text)
|
||||
expect(identifiedTexts[0]).toContain('Harlequin Crest')
|
||||
expect(identifiedTexts).not.toContain('未辨识 (Unidentified)')
|
||||
expect(identifiedTexts).not.toContain('无法使用 (Cannot be Equipped)')
|
||||
expect(identifiedTexts.length).toBeGreaterThan(texts.length)
|
||||
})
|
||||
})
|
||||
|
||||
describe('5. End-to-End Identification Flows: Scroll (isc), Tome (ibk), and Deckard Cain', () => {
|
||||
it('identifies an item using a Scroll of Identify (isc), consumes the scroll, and unlocks equipping', () => {
|
||||
const inv = new InventoryPanel()
|
||||
inv.visible = true
|
||||
delete inv.equipped.helm
|
||||
|
||||
const uniqueShako = itemToUiInventoryItem(makeDroppedShako('unique', 31), dropTables)
|
||||
const iscBase = dropTables.getBase('isc')!
|
||||
const scrollItem = itemToUiInventoryItem(
|
||||
createDroppedItem(iscBase, 'normal', {
|
||||
itemTypes: dropTables.itemTypes,
|
||||
ilvl: 1,
|
||||
dwInitSeed: 32,
|
||||
itemRng: new D2Rng(32),
|
||||
}),
|
||||
dropTables,
|
||||
)
|
||||
|
||||
inv.gridItems = [
|
||||
{ item: uniqueShako, col: 0, row: 0 },
|
||||
{ item: scrollItem, col: 2, row: 0 },
|
||||
]
|
||||
|
||||
// Right-click Scroll of Identify at (2, 0) via handleClick(button=2) to enter Identify Cursor Mode
|
||||
const scrollX = INV_GRID_ORIGIN.x + 2 * INV_GRID_ORIGIN.cellPx + 5
|
||||
const scrollY = INV_GRID_ORIGIN.y + 5
|
||||
expect(inv.handleClick(scrollX, scrollY, undefined, 2)).toBe(true)
|
||||
expect(inv.isIdentifyMode()).toBe(true)
|
||||
|
||||
// Left-click the unidentified Shako at (0, 0) via handleClick(button=0) to identify it
|
||||
const shakoX = INV_GRID_ORIGIN.x + 5
|
||||
const shakoY = INV_GRID_ORIGIN.y + 5
|
||||
expect(inv.handleClick(shakoX, shakoY, undefined, 0)).toBe(true)
|
||||
expect(inv.isIdentifyMode()).toBe(false)
|
||||
expect(isUiItemUnidentified(uniqueShako)).toBe(false)
|
||||
expect((uniqueShako.rawItem!.rawFlags! & ItemFlag.IDENTIFIED) !== 0).toBe(true)
|
||||
|
||||
// Scroll was consumed and removed from inventory
|
||||
expect(inv.gridItems.some(p => p.item.id === scrollItem.id)).toBe(false)
|
||||
|
||||
// Now pick up the identified Shako and equip it into the helm slot
|
||||
expect(inv.clickGridCell(0, 0)).toBe(true)
|
||||
expect(inv.cursorItem?.id).toBe(uniqueShako.id)
|
||||
expect(inv.clickEquipSlot('helm')).toBe(true)
|
||||
expect((inv.equipped.helm as UiInventoryItem | undefined)?.id).toBe(uniqueShako.id)
|
||||
expect(inv.cursorItem).toBeNull()
|
||||
})
|
||||
|
||||
it('identifies an item using a Tome of Identify (ibk) and decrements tome quantity', () => {
|
||||
const inv = new InventoryPanel()
|
||||
inv.visible = true
|
||||
const rinBase = dropTables.getBase('rin')!
|
||||
const ibkBase = dropTables.getBase('ibk')!
|
||||
|
||||
const rareRing = itemToUiInventoryItem(
|
||||
createDroppedItem(rinBase, 'rare', {
|
||||
itemTypes: dropTables.itemTypes,
|
||||
ilvl: 60,
|
||||
dwInitSeed: 41,
|
||||
itemRng: new D2Rng(41),
|
||||
}),
|
||||
dropTables,
|
||||
)
|
||||
const tomeItem = itemToUiInventoryItem(
|
||||
{
|
||||
...createDroppedItem(ibkBase, 'normal', {
|
||||
itemTypes: dropTables.itemTypes,
|
||||
ilvl: 1,
|
||||
dwInitSeed: 42,
|
||||
itemRng: new D2Rng(42),
|
||||
}),
|
||||
quantity: 5,
|
||||
stack: 5,
|
||||
},
|
||||
dropTables,
|
||||
)
|
||||
|
||||
inv.gridItems = [
|
||||
{ item: rareRing, col: 0, row: 0 },
|
||||
{ item: tomeItem, col: 1, row: 0 },
|
||||
]
|
||||
|
||||
const tomeX = INV_GRID_ORIGIN.x + 1 * INV_GRID_ORIGIN.cellPx + 5
|
||||
const tomeY = INV_GRID_ORIGIN.y + 5
|
||||
expect(inv.handleClick(tomeX, tomeY, undefined, 2)).toBe(true)
|
||||
expect(inv.isIdentifyMode()).toBe(true)
|
||||
|
||||
const ringX = INV_GRID_ORIGIN.x + 5
|
||||
const ringY = INV_GRID_ORIGIN.y + 5
|
||||
expect(inv.handleClick(ringX, ringY, undefined, 0)).toBe(true)
|
||||
expect(isUiItemUnidentified(rareRing)).toBe(false)
|
||||
expect(tomeItem.quantity).toBe(4)
|
||||
expect(inv.gridItems.some(p => p.item.id === tomeItem.id)).toBe(true)
|
||||
})
|
||||
|
||||
it('identifies all unidentified items across Inventory, Stash, and Horadric Cube via Deckard Cain', () => {
|
||||
const inv = new InventoryPanel()
|
||||
const wp = new WorldPanelsHud()
|
||||
const rinBase = dropTables.getBase('rin')!
|
||||
const amuBase = dropTables.getBase('amu')!
|
||||
|
||||
const bagUnid = itemToUiInventoryItem(makeDroppedShako('unique', 51), dropTables)
|
||||
const stashUnid = itemToUiInventoryItem(
|
||||
createDroppedItem(rinBase, 'rare', {
|
||||
itemTypes: dropTables.itemTypes,
|
||||
ilvl: 60,
|
||||
dwInitSeed: 52,
|
||||
itemRng: new D2Rng(52),
|
||||
}),
|
||||
dropTables,
|
||||
)
|
||||
const cubeUnid = itemToUiInventoryItem(
|
||||
createDroppedItem(amuBase, 'magic', {
|
||||
itemTypes: dropTables.itemTypes,
|
||||
ilvl: 50,
|
||||
dwInitSeed: 53,
|
||||
itemRng: new D2Rng(53),
|
||||
}),
|
||||
dropTables,
|
||||
)
|
||||
|
||||
inv.gridItems = [{ item: bagUnid, col: 0, row: 0 }]
|
||||
wp.stashItems = [{ item: stashUnid, col: 0, row: 0 }]
|
||||
wp.cubeItems = [{ item: cubeUnid, col: 0, row: 0 }]
|
||||
|
||||
expect(isUiItemUnidentified(bagUnid)).toBe(true)
|
||||
expect(isUiItemUnidentified(stashUnid)).toBe(true)
|
||||
expect(isUiItemUnidentified(cubeUnid)).toBe(true)
|
||||
|
||||
const count = wp.identifyAllUiItems(inv)
|
||||
expect(count).toBe(3)
|
||||
expect(isUiItemUnidentified(bagUnid)).toBe(false)
|
||||
expect(isUiItemUnidentified(stashUnid)).toBe(false)
|
||||
expect(isUiItemUnidentified(cubeUnid)).toBe(false)
|
||||
expect(wp.identifyAllUiItems(inv)).toBe(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -10,7 +10,7 @@ import { MpqArchive } from '../src/mpq/archive.ts'
|
|||
import { fileSource } from '../src/mpq/file-source.ts'
|
||||
import { MountedArchives } from '../src/mpq/mount.ts'
|
||||
import { BAKED_UI_MANIFEST } from '../src/ui/baked-ui-meta.ts'
|
||||
import type { D2ColorCode, D2FontRenderer, FontSizeName } from '../src/ui/font.ts'
|
||||
import type { D2ColorCode, D2FontName as FontSizeName, D2FontRenderer } from '../src/ui/font.ts'
|
||||
import { InventoryPanel, type UiInventoryItem } from '../src/ui/inventory.ts'
|
||||
import {
|
||||
BLACKSMITH_CLASS_IDS,
|
||||
|
|
@ -364,17 +364,19 @@ describe('Diablo II 1.13c Vendor / BuySell Panel UI Parity', () => {
|
|||
const helm: UiInventoryItem = {
|
||||
id: 'damaged-cap',
|
||||
code: 'cap',
|
||||
nameEn: 'Cap',
|
||||
name: 'Cap',
|
||||
nameZh: '帽子',
|
||||
baseNameZh: '帽子',
|
||||
quality: 'normal',
|
||||
invFile: 'invcap',
|
||||
invWidth: 2,
|
||||
invHeight: 2,
|
||||
equipSlots: ['head'],
|
||||
allowedSlots: ['helm'],
|
||||
stats: [],
|
||||
identified: true,
|
||||
durability: { current: 4, max: 12 },
|
||||
}
|
||||
inv.equipped.head = helm
|
||||
inv.equipped.helm = helm
|
||||
const expectedCost = wp.getRepairAllCost(inv)
|
||||
expect(expectedCost).toBeGreaterThan(0)
|
||||
|
||||
|
|
@ -449,7 +451,7 @@ describe('Diablo II 1.13c Vendor / BuySell Panel UI Parity', () => {
|
|||
expect(wp.activeVendorTab).toBe('armor')
|
||||
|
||||
// Leave only 1 non-permStore item on the armor tab and buy it
|
||||
const nonPerm = wp.vendorTabPlacements.armor.find(p => !p.item.rawItem?.permStoreItem)
|
||||
const nonPerm = wp.vendorTabPlacements.armor.find(p => !p.item.permStore && !p.item.rawItem?.permStore)
|
||||
expect(nonPerm).toBeDefined()
|
||||
wp.vendorTabPlacements.armor = [nonPerm!]
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue