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 }
|
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.
|
* Creates a concrete Item instance for a dropped leaf item.
|
||||||
*/
|
*/
|
||||||
|
|
@ -587,10 +602,12 @@ export function createDroppedItem(
|
||||||
readonly dwInitSeed: number
|
readonly dwInitSeed: number
|
||||||
readonly durabilityMultiplier?: number | undefined
|
readonly durabilityMultiplier?: number | undefined
|
||||||
readonly itemRng?: D2Rng | undefined
|
readonly itemRng?: D2Rng | undefined
|
||||||
|
readonly identified?: boolean | undefined
|
||||||
},
|
},
|
||||||
): Item {
|
): Item {
|
||||||
const quality = QUALITY_TIER_TO_ITEM_QUALITY[qualityTier] ?? ItemQuality.NORMAL
|
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 name = base.name
|
||||||
let prefix = null
|
let prefix = null
|
||||||
|
|
@ -894,6 +911,7 @@ export function createDroppedItem(
|
||||||
value: base.value,
|
value: base.value,
|
||||||
rawFlags,
|
rawFlags,
|
||||||
flags: decodeItemFlags(rawFlags),
|
flags: decodeItemFlags(rawFlags),
|
||||||
|
identified: isIdentified,
|
||||||
version: 0x65,
|
version: 0x65,
|
||||||
code: formatItemCode(base.id),
|
code: formatItemCode(base.id),
|
||||||
quality,
|
quality,
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,12 @@ import {
|
||||||
rayTraceScene,
|
rayTraceScene,
|
||||||
} from './d2map.ts'
|
} from './d2map.ts'
|
||||||
import type { CollisionGrid } 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 { isRuneCode } from './gems-data.ts'
|
||||||
import { getEmbeddedDropTables } from './embedded-drop-tables.ts'
|
import { getEmbeddedDropTables } from './embedded-drop-tables.ts'
|
||||||
|
|
||||||
|
|
@ -181,6 +186,25 @@ export interface GroundItemMetadata {
|
||||||
readonly sockets?: number | undefined
|
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. */
|
/** Extract ground item metadata from any item source. */
|
||||||
export function resolveGroundItemMetadata(item: any, specifiedAmount?: number): GroundItemMetadata {
|
export function resolveGroundItemMetadata(item: any, specifiedAmount?: number): GroundItemMetadata {
|
||||||
const isGold = isGoldItem(item)
|
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'
|
const rawCode = typeof item?.code === 'string'
|
||||||
? item.code.trim().toLowerCase()
|
? item.code.trim().toLowerCase()
|
||||||
: typeof item?.base?.id === 'string'
|
: typeof item?.base?.id === 'string'
|
||||||
? item.base.id.trim().toLowerCase()
|
? item.base.id.trim().toLowerCase()
|
||||||
: ''
|
: typeof item?.rawItem?.code === 'string'
|
||||||
const quality = resolveItemQualityString(item?.quality ?? item?.base?.quality, rawCode, item)
|
? item.rawItem.code.trim().toLowerCase()
|
||||||
const nameZh = formatBilingualGroundItemName(item, name, quality)
|
: typeof item?.rawItem?.base?.id === 'string'
|
||||||
if (item && typeof item === 'object' && (!item.nameZh || item.nameZh === name) && nameZh) {
|
? item.rawItem.base.id.trim().toLowerCase()
|
||||||
try {
|
: ''
|
||||||
item.nameZh = nameZh
|
|
||||||
} catch {
|
|
||||||
// ignore if frozen object
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let tableBase: any
|
let tableBase: any
|
||||||
if (rawCode && (!item?.base || (item?.base?.dropsound === undefined && item?.dropSound === undefined && item?.dropsound === undefined))) {
|
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 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 invHeight = typeof item?.invHeight === 'number' ? item.invHeight : (item?.base?.invHeight ?? tableBase?.invHeight ?? 1)
|
||||||
const amount = typeof item?.stack === 'number' ? item.stack : 1
|
const amount = typeof item?.stack === 'number' ? item.stack : 1
|
||||||
|
|
|
||||||
|
|
@ -510,7 +510,8 @@ export function calculateTransactionCost(options: TransactionCostOptions): numbe
|
||||||
nRepCost = afterSkill.repCost
|
nRepCost = afterSkill.repCost
|
||||||
}
|
}
|
||||||
|
|
||||||
const isIdentified = item.flags?.identified !== false && !item.isGambleMystery
|
const isIdentified =
|
||||||
|
item.identified !== false && item.flags?.identified !== false && !item.isGambleMystery
|
||||||
|
|
||||||
if (isIdentified) {
|
if (isIdentified) {
|
||||||
let nSell = 0
|
let nSell = 0
|
||||||
|
|
|
||||||
|
|
@ -350,6 +350,7 @@ export function generateVendorStoreItem(options: {
|
||||||
ilvl: nItemLevel,
|
ilvl: nItemLevel,
|
||||||
dwInitSeed,
|
dwInitSeed,
|
||||||
itemRng,
|
itemRng,
|
||||||
|
identified: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Roll authentic armor defense in [minac, maxac] (or maxac + 1 if enhanced defense)
|
// Roll authentic armor defense in [minac, maxac] (or maxac + 1 if enhanced defense)
|
||||||
|
|
@ -779,6 +780,7 @@ export function resolveGambledItem(options: {
|
||||||
ilvl,
|
ilvl,
|
||||||
dwInitSeed,
|
dwInitSeed,
|
||||||
itemRng,
|
itemRng,
|
||||||
|
identified: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,13 @@
|
||||||
* - Clickable labels to trigger pathfinding and item pickup.
|
* - Clickable labels to trigger pathfinding and item pickup.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { GroundItemEntity } from '../game/ground-items.ts'
|
import { isGroundSourceUnidentified, type GroundItemEntity } from '../game/ground-items.ts'
|
||||||
import { translateItemName, translateRareName, translateMagicName } from '../game/tooltip-i18n.ts'
|
import {
|
||||||
|
resolveBaseItemNameEn,
|
||||||
|
translateItemName,
|
||||||
|
translateRareName,
|
||||||
|
translateMagicName,
|
||||||
|
} from '../game/tooltip-i18n.ts'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Authentic 1.13c Diablo II quality color codes for ground item labels.
|
* 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) {
|
if (item.isGold) {
|
||||||
return `${item.amount} 金币`
|
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) {
|
if (item.nameZh) {
|
||||||
return item.nameZh
|
return item.nameZh
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ export interface UiInventoryItem {
|
||||||
readonly id: string
|
readonly id: string
|
||||||
readonly code: string
|
readonly code: string
|
||||||
readonly invFile: string
|
readonly invFile: string
|
||||||
|
readonly baseInvFile?: string | undefined
|
||||||
readonly name: string
|
readonly name: string
|
||||||
readonly nameZh: string
|
readonly nameZh: string
|
||||||
readonly baseNameZh: string
|
readonly baseNameZh: string
|
||||||
|
|
@ -146,6 +147,7 @@ export interface ResolvableItem {
|
||||||
readonly nameZh?: string | undefined
|
readonly nameZh?: string | undefined
|
||||||
readonly baseNameZh?: string | undefined
|
readonly baseNameZh?: string | undefined
|
||||||
readonly invFile?: string | undefined
|
readonly invFile?: string | undefined
|
||||||
|
readonly baseInvFile?: string | undefined
|
||||||
readonly flippyFile?: string | undefined
|
readonly flippyFile?: string | undefined
|
||||||
readonly isGold?: boolean | undefined
|
readonly isGold?: boolean | undefined
|
||||||
readonly amount?: number | undefined
|
readonly amount?: number | undefined
|
||||||
|
|
@ -158,6 +160,9 @@ export interface ResolvableItem {
|
||||||
readonly invHeight?: number | undefined
|
readonly invHeight?: number | undefined
|
||||||
readonly width?: number | undefined
|
readonly width?: number | undefined
|
||||||
readonly height?: 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,
|
itemRects: Record<string, SpriteRect> = BAKED_UI_MANIFEST.itemRects,
|
||||||
): SpriteRect | null {
|
): SpriteRect | null {
|
||||||
const normalizedCode = normalizeItemCode(rawItem)
|
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}`
|
// Tier 0: Transformed dyed item lookup: `${invFile}_${invtransform}`
|
||||||
const targetInv =
|
const targetInv =
|
||||||
item.invFile ||
|
item.invFile ||
|
||||||
|
|
@ -1056,6 +1082,8 @@ export function identifyUiItem(item: UiInventoryItem): UiInventoryItem {
|
||||||
}
|
}
|
||||||
const rebuilt = itemToUiInventoryItem(item.rawItem)
|
const rebuilt = itemToUiInventoryItem(item.rawItem)
|
||||||
Object.assign(item, {
|
Object.assign(item, {
|
||||||
|
invFile: rebuilt.invFile,
|
||||||
|
...(rebuilt.baseInvFile !== undefined ? { baseInvFile: rebuilt.baseInvFile } : {}),
|
||||||
name: rebuilt.name,
|
name: rebuilt.name,
|
||||||
nameZh: rebuilt.nameZh,
|
nameZh: rebuilt.nameZh,
|
||||||
baseNameZh: rebuilt.baseNameZh,
|
baseNameZh: rebuilt.baseNameZh,
|
||||||
|
|
@ -1645,8 +1673,9 @@ export class InventoryPanel {
|
||||||
const gw = placed.item.invWidth * INV_GRID_ORIGIN.cellPx
|
const gw = placed.item.invWidth * INV_GRID_ORIGIN.cellPx
|
||||||
const gh = placed.item.invHeight * INV_GRID_ORIGIN.cellPx
|
const gh = placed.item.invHeight * INV_GRID_ORIGIN.cellPx
|
||||||
|
|
||||||
ctx.fillStyle =
|
ctx.fillStyle = isUiItemUnidentified(placed.item)
|
||||||
placed.item.quality === 'unique'
|
? 'rgba(72, 18, 18, 0.50)'
|
||||||
|
: placed.item.quality === 'unique'
|
||||||
? 'rgba(58, 42, 16, 0.45)'
|
? 'rgba(58, 42, 16, 0.45)'
|
||||||
: placed.item.quality === 'set'
|
: placed.item.quality === 'set'
|
||||||
? 'rgba(16, 52, 22, 0.45)'
|
? 'rgba(16, 52, 22, 0.45)'
|
||||||
|
|
|
||||||
|
|
@ -383,6 +383,12 @@ export function itemToUiInventoryItem(
|
||||||
|
|
||||||
// 3. invFile & Dye Resolution
|
// 3. invFile & Dye Resolution
|
||||||
const { invFile, invtransform, chrtransform } = resolveItemInvFile(rawItem, quality, code, dropTables)
|
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
|
// 4. Allowed Equipment Slots
|
||||||
const allowedSlots = resolveAllowedSlots(
|
const allowedSlots = resolveAllowedSlots(
|
||||||
|
|
@ -494,6 +500,7 @@ export function itemToUiInventoryItem(
|
||||||
id,
|
id,
|
||||||
code,
|
code,
|
||||||
invFile,
|
invFile,
|
||||||
|
baseInvFile,
|
||||||
name: rawItem.name || baseEn,
|
name: rawItem.name || baseEn,
|
||||||
nameZh,
|
nameZh,
|
||||||
baseNameZh,
|
baseNameZh,
|
||||||
|
|
|
||||||
|
|
@ -2420,12 +2420,19 @@ export class WorldPanelsHud {
|
||||||
: 'white'
|
: 'white'
|
||||||
|
|
||||||
// Header: Title, Runeword Runes line, Base item name
|
// Header: Title, Runeword Runes line, Base item name
|
||||||
lines.push({ text: item.nameZh, color: titleColor, font: 'fontexocet10' })
|
const unid = isUiItemUnidentified(item)
|
||||||
if (item.runewordRunes) {
|
if (unid) {
|
||||||
lines.push({ text: item.runewordRunes, color: 'gold', font: 'font8' })
|
const maskedBaseHeader =
|
||||||
}
|
(item.baseNameZh ? item.baseNameZh.split(' · ')[0] : undefined) || item.nameZh
|
||||||
if (item.baseNameZh) {
|
lines.push({ text: maskedBaseHeader, color: titleColor, font: 'fontexocet10' })
|
||||||
lines.push({ text: item.baseNameZh, color: 'white', font: 'font8' })
|
} 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)
|
// Vendor Transaction Cost Line (`ITEMS_CalculateTransactionCost` at 0x6FD9B1C0)
|
||||||
|
|
@ -2467,33 +2474,52 @@ export class WorldPanelsHud {
|
||||||
if (item.reqStr !== undefined) {
|
if (item.reqStr !== undefined) {
|
||||||
lines.push({ text: '需要力量: ' + item.reqStr, color: 'white', font: 'font8' })
|
lines.push({ text: '需要力量: ' + item.reqStr, color: 'white', font: 'font8' })
|
||||||
}
|
}
|
||||||
if (item.reqLevel !== undefined) {
|
const displayReqLevel = unid
|
||||||
lines.push({ text: '需要等级: ' + item.reqLevel, color: 'white', font: 'font8' })
|
? 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
|
if (unid) {
|
||||||
for (const st of item.stats) {
|
if (item.isGambleMystery) {
|
||||||
lines.push({ text: st.text, color: st.color ?? 'blue', font: 'font8' })
|
lines.push({ text: '未鉴定 (Unidentified Gamble Item)', color: 'red', font: 'font8' })
|
||||||
}
|
} else {
|
||||||
|
lines.push({ text: '未辨识 (Unidentified)', color: 'red', font: 'font8' })
|
||||||
// Sockets Block
|
if (item.allowedSlots.length > 0) {
|
||||||
if (
|
lines.push({ text: '无法使用 (Cannot be Equipped)', color: 'red', font: 'font8' })
|
||||||
item.sockets !== undefined &&
|
}
|
||||||
item.sockets > 0 &&
|
if (item.ethereal) {
|
||||||
!item.stats.some(s => s.text.includes('凹槽') || s.text.includes('Socketed'))
|
lines.push({ text: '无形的 (无法修复) (Ethereal)', color: 'blue', font: 'font8' })
|
||||||
) {
|
}
|
||||||
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' })
|
|
||||||
}
|
}
|
||||||
}
|
} else {
|
||||||
if (item.setBonuses) {
|
// Magic Affixes
|
||||||
for (const bonus of item.setBonuses) {
|
for (const st of item.stats) {
|
||||||
lines.push({ text: bonus.text, color: bonus.color ?? 'green', font: 'font8' })
|
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 {
|
import {
|
||||||
InventoryPanel,
|
InventoryPanel,
|
||||||
resolveItemSpriteRect,
|
resolveItemSpriteRect,
|
||||||
|
identifyUiItem,
|
||||||
|
isUiItemUnidentified,
|
||||||
PLAYER_GOLD_CAP,
|
PLAYER_GOLD_CAP,
|
||||||
type UiInventoryItem,
|
type UiInventoryItem,
|
||||||
type EquipSlotId,
|
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.equipped[invalidSlot]).toBeUndefined()
|
||||||
expect(invPanel.cursorItem).toBe(placed!.item)
|
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
|
// Equip into valid matching slot
|
||||||
const validEquip = invPanel.clickEquipSlot(equippableSlot!)
|
const validEquip = invPanel.clickEquipSlot(equippableSlot!)
|
||||||
expect(validEquip).toBe(true)
|
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 { fileSource } from '../src/mpq/file-source.ts'
|
||||||
import { MountedArchives } from '../src/mpq/mount.ts'
|
import { MountedArchives } from '../src/mpq/mount.ts'
|
||||||
import { BAKED_UI_MANIFEST } from '../src/ui/baked-ui-meta.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 { InventoryPanel, type UiInventoryItem } from '../src/ui/inventory.ts'
|
||||||
import {
|
import {
|
||||||
BLACKSMITH_CLASS_IDS,
|
BLACKSMITH_CLASS_IDS,
|
||||||
|
|
@ -364,17 +364,19 @@ describe('Diablo II 1.13c Vendor / BuySell Panel UI Parity', () => {
|
||||||
const helm: UiInventoryItem = {
|
const helm: UiInventoryItem = {
|
||||||
id: 'damaged-cap',
|
id: 'damaged-cap',
|
||||||
code: 'cap',
|
code: 'cap',
|
||||||
nameEn: 'Cap',
|
name: 'Cap',
|
||||||
nameZh: '帽子',
|
nameZh: '帽子',
|
||||||
|
baseNameZh: '帽子',
|
||||||
quality: 'normal',
|
quality: 'normal',
|
||||||
invFile: 'invcap',
|
invFile: 'invcap',
|
||||||
invWidth: 2,
|
invWidth: 2,
|
||||||
invHeight: 2,
|
invHeight: 2,
|
||||||
equipSlots: ['head'],
|
allowedSlots: ['helm'],
|
||||||
|
stats: [],
|
||||||
identified: true,
|
identified: true,
|
||||||
durability: { current: 4, max: 12 },
|
durability: { current: 4, max: 12 },
|
||||||
}
|
}
|
||||||
inv.equipped.head = helm
|
inv.equipped.helm = helm
|
||||||
const expectedCost = wp.getRepairAllCost(inv)
|
const expectedCost = wp.getRepairAllCost(inv)
|
||||||
expect(expectedCost).toBeGreaterThan(0)
|
expect(expectedCost).toBeGreaterThan(0)
|
||||||
|
|
||||||
|
|
@ -449,7 +451,7 @@ describe('Diablo II 1.13c Vendor / BuySell Panel UI Parity', () => {
|
||||||
expect(wp.activeVendorTab).toBe('armor')
|
expect(wp.activeVendorTab).toBe('armor')
|
||||||
|
|
||||||
// Leave only 1 non-permStore item on the armor tab and buy it
|
// 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()
|
expect(nonPerm).toBeDefined()
|
||||||
wp.vendorTabPlacements.armor = [nonPerm!]
|
wp.vendorTabPlacements.armor = [nonPerm!]
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue